diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 7a3c2648..18a96a73 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -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(() => { @@ -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], @@ -975,8 +974,8 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) {(card.type ?? "text") === "text" && ( handleSendToOutline(card)} + text={t("sendToTimeline")} + action={() => handleSendToTimeline(card)} /> )} | 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(null); @@ -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(() => { @@ -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 @@ -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) { @@ -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. @@ -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]); @@ -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; } }; @@ -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; @@ -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"); @@ -824,7 +881,7 @@ const DocumentEditorPanel = ({ } >
) 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 { @@ -204,44 +213,30 @@ 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 ) 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; @@ -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 { diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css new file mode 100644 index 00000000..a4aca354 --- /dev/null +++ b/components/editor/MobileFormatToolbar.module.css @@ -0,0 +1,68 @@ +/* Phone-only formatting bar pinned just above the on-screen keyboard. `bottom` + * is set inline from the tracked keyboard inset (see MobileFormatToolbar.tsx). */ +.toolbar { + position: fixed; + left: 0; + right: 0; + z-index: 60; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + + padding: 6px 12px; + padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); + + background-color: var(--secondary); + border-top: 1px solid var(--separator); + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12); + + user-select: none; + -webkit-user-select: none; + /* Belt-and-braces with the pointer-down preventDefault: never let a tap here + * steal focus / dismiss the keyboard. */ + -webkit-tap-highlight-color: transparent; +} + +.group { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--secondary-text); + cursor: pointer; + touch-action: manipulation; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.btn:active { + background-color: var(--secondary-hover); +} + +.btn.active { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +.separator { + width: 1px; + height: 22px; + background-color: var(--separator); + margin-inline: 4px; +} diff --git a/components/editor/MobileFormatToolbar.tsx b/components/editor/MobileFormatToolbar.tsx new file mode 100644 index 00000000..1b35ae7c --- /dev/null +++ b/components/editor/MobileFormatToolbar.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, Underline } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { useIsPhone } from "@src/lib/utils/hooks"; +import { applyMarkToggle } from "@src/lib/screenplay/editor"; +import { applyTitlePageMarkToggle } from "@src/lib/titlepage/editor"; +import { Style } from "@src/lib/utils/enums"; +import { join } from "@src/lib/utils/misc"; + +import styles from "./MobileFormatToolbar.module.css"; + +// 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; +}; + +/** + * Phone-only formatting bar that rides just above the on-screen keyboard while a + * screenplay/title editor is focused. Surfaces the inline styling (bold, italic, + * underline) and alignment controls that are dropped from the narrow navbar + * (see the max-width:767px block in ScreenplayFormatDropdown.module.css). + */ +const MobileFormatToolbar = () => { + const isPhone = useIsPhone(); + const { + editor, + draftEditor, + titlePageEditor, + focusedEditorType, + selectedStyles, + setSelectedStyles, + isReadOnly, + } = useContext(ProjectContext); + + const keyboardInset = useKeyboardInset(isPhone); + + const isTitleContext = focusedEditorType === "title"; + const isDraftContext = focusedEditorType === "draft"; + const activeEditor = isTitleContext ? titlePageEditor : isDraftContext ? draftEditor : editor; + + const [selectedAlign, setSelectedAlign] = useState("left"); + + // Keep the alignment highlight in sync with the caret's block. + useEffect(() => { + if (!activeEditor) return; + const updateAlign = () => { + const align = activeEditor.state.selection.$anchor.parent.attrs.textAlign || "left"; + setSelectedAlign(align); + }; + activeEditor.on("selectionUpdate", updateAlign); + activeEditor.on("transaction", updateAlign); + updateAlign(); + return () => { + activeEditor.off("selectionUpdate", updateAlign); + activeEditor.off("transaction", updateAlign); + }; + }, [activeEditor]); + + const toggleStyle = useCallback( + (style: Style) => { + if (isReadOnly || !activeEditor) return; + setSelectedStyles((prev) => (prev ^ style) as Style); + if (isTitleContext) { + applyTitlePageMarkToggle(activeEditor, style); + } else { + applyMarkToggle(activeEditor, style); + } + }, + [activeEditor, isTitleContext, isReadOnly, setSelectedStyles], + ); + + const setAlignment = useCallback( + (align: string) => { + if (isReadOnly || !activeEditor) return; + setSelectedAlign(align); + if (isTitleContext) { + activeEditor.chain().focus().updateAttributes("tp-text", { textAlign: align }).run(); + } else { + const nodeType = activeEditor.state.selection.$anchor.parent.type.name; + activeEditor + .chain() + .focus() + .updateAttributes(nodeType, { textAlign: align === "left" ? null : align }) + .run(); + } + }, + [activeEditor, isTitleContext, isReadOnly], + ); + + // Only mount on phone, once a text editor is focused and the keyboard is up. + const isVisible = isPhone && keyboardInset > 0 && !!activeEditor && focusedEditorType !== null; + if (!isVisible) return null; + + // Fire on pointer-down and swallow the event so focus (and the on-screen + // keyboard) stays on the editor — a normal click would blur it first. + const action = (fn: () => void) => (e: React.PointerEvent) => { + e.preventDefault(); + fn(); + }; + + const styleBtn = (active: boolean) => join(styles.btn, active ? styles.active : ""); + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+
+ ); +}; + +export default MobileFormatToolbar; diff --git a/components/editor/outline/OutlineItem.module.css b/components/editor/outline/OutlineItem.module.css deleted file mode 100644 index 6b514b69..00000000 --- a/components/editor/outline/OutlineItem.module.css +++ /dev/null @@ -1,141 +0,0 @@ -.block { - position: relative; - display: flex; - align-items: stretch; - box-sizing: border-box; - /* Board-card look: a full-color strip on the left and a tinted body, driven - by `--card-color` (set inline from the element's color). */ - background-color: color-mix(in srgb, var(--card-color, white) 20%, white); - cursor: pointer; - transition: background-color 0.12s ease; -} - -.block:hover { - background-color: color-mix(in srgb, var(--card-color, white) 20%, #ececec); -} - -.block_missing { - opacity: 0.6; -} - -/* Drop indicators */ -.block_drop_into::after { - content: ""; - position: absolute; - inset: 0; - border: 2px solid var(--tertiary-hover); - pointer-events: none; - z-index: 1; -} - -.block_drop_before::before, -.block_drop_after::after { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 2px; - background-color: var(--tertiary-hover); - pointer-events: none; - z-index: 1; -} - -.block_drop_before::before { - top: 0; -} - -.block_drop_after::after { - bottom: 0; -} - -/* Full-color strip (board card header); stretches to full height. */ -.color_strip { - flex-shrink: 0; - width: 8px; - background-color: var(--card-color, var(--tertiary)); -} - -/* Body (board card body) — transparent so the block's tint and drop highlights - show through. */ -.body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - padding: 9px 12px; -} - -.title_row { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - min-width: 0; -} - -.type_icon { - flex-shrink: 0; - color: #64748b; -} - -.title { - flex: 1; - min-width: 0; - font-size: 13px; - font-weight: 600; - color: var(--board-card-body-text); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.unlinked { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 6px; - border-radius: 10px; - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; - color: #64748b; - background-color: rgba(0, 0, 0, 0.06); -} - -.remove_btn { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 3px; - border: none; - border-radius: 4px; - background: none; - color: #64748b; - cursor: pointer; - opacity: 0; - transition: - opacity 0.12s ease, - background-color 0.12s ease; -} - -.block:hover .remove_btn { - opacity: 1; -} - -.remove_btn:hover { - background-color: rgba(0, 0, 0, 0.06); - color: var(--board-card-body-text); -} - -.preview { - margin: 0; - font-size: 12px; - color: #64748b; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} diff --git a/components/editor/outline/OutlineItem.tsx b/components/editor/outline/OutlineItem.tsx deleted file mode 100644 index a811655f..00000000 --- a/components/editor/outline/OutlineItem.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { useTranslations } from "next-intl"; -import { OutlineItem as OutlineItemType } from "@src/lib/project/project-state"; -import { join } from "@src/lib/utils/misc"; -import { Film, Unlink, X } from "lucide-react"; -import type { ResolvedOutline } from "./OutlineView"; - -import styles from "./OutlineItem.module.css"; - -export type DropPosition = "into" | "before" | "after"; - -export interface OutlineItemProps { - node: OutlineItemType; - depth: number; - childrenOf: (parentId: string | null) => OutlineItemType[]; - resolved: Record; - onNavigate: (node: OutlineItemType) => void; - onRemove: (id: string) => void; - // Drag & drop - draggingId: string | null; - dropTarget: { id: string; pos: DropPosition } | null; - onDragStart: (id: string) => void; - onDragOverNode: (id: string, pos: DropPosition) => void; - onDropNode: (id: string) => void; - onDragEnd: () => void; -} - -const INDENT = 36; - -const OutlineItem = ({ - node, - depth, - childrenOf, - resolved, - onNavigate, - onRemove, - draggingId, - dropTarget, - onDragStart, - onDragOverNode, - onDropNode, - onDragEnd, -}: OutlineItemProps) => { - const t = useTranslations("outline"); - - const r = resolved[node.id] ?? { title: node.title, preview: node.preview, color: node.color, missing: true }; - const isCard = node.source === "card"; - const children = childrenOf(node.id); - - // Every block can nest children, so use folder-style drop thresholds: - // top 25% = before, middle 50% = into, bottom 25% = after. - const handleDragOver = useCallback( - (e: React.DragEvent) => { - if (!draggingId || draggingId === node.id) return; - e.preventDefault(); - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - const y = e.clientY - rect.top; - let pos: DropPosition; - if (y < rect.height * 0.25) pos = "before"; - else if (y > rect.height * 0.75) pos = "after"; - else pos = "into"; - onDragOverNode(node.id, pos); - }, - [draggingId, node.id, onDragOverNode], - ); - - const isDropTarget = dropTarget?.id === node.id; - const blockClass = join( - styles.block, - r.missing ? styles.block_missing : "", - isDropTarget && dropTarget?.pos === "into" ? styles.block_drop_into : "", - isDropTarget && dropTarget?.pos === "before" ? styles.block_drop_before : "", - isDropTarget && dropTarget?.pos === "after" ? styles.block_drop_after : "", - ); - - return ( - <> -
-
onNavigate(node)} - draggable - onDragStart={(e) => { - e.dataTransfer.effectAllowed = "move"; - onDragStart(node.id); - }} - onDragOver={handleDragOver} - onDrop={(e) => { - e.preventDefault(); - e.stopPropagation(); - onDropNode(node.id); - }} - onDragEnd={onDragEnd} - > -
-
-
- {!isCard && } - {r.title || t("untitled")} - {r.missing && ( - - - {t("unlinked")} - - )} - -
- {r.preview &&

{r.preview}

} -
-
-
- - {children.map((child) => ( - - ))} - - ); -}; - -export default OutlineItem; diff --git a/components/editor/outline/OutlinePanel.tsx b/components/editor/outline/OutlinePanel.tsx deleted file mode 100644 index fdd99f74..00000000 --- a/components/editor/outline/OutlinePanel.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useContext } from "react"; -import { useTranslations } from "next-intl"; -import { ProjectContext } from "@src/context/ProjectContext"; -import OutlineView from "./OutlineView"; -import { ListTree } from "lucide-react"; - -import styles from "../EditorPanel.module.css"; - -const EmptyOutlineState = () => { - const t = useTranslations("outline"); - - return ( -
- -

{t("empty")}

-
- ); -}; - -/** - * The Outline view: a project-wide, ordered, nestable list of blocks that - * reference scene headings and board cards. Writers reorder/indent blocks to - * sequence their story beats. Shows an empty state until something is sent here. - */ -const OutlinePanel = ({ isVisible }: { isVisible: boolean }) => { - const { outline } = useContext(ProjectContext); - - if (Object.keys(outline).length === 0) { - return ; - } - - return ; -}; - -export default OutlinePanel; diff --git a/components/editor/outline/OutlineView.module.css b/components/editor/outline/OutlineView.module.css deleted file mode 100644 index 679f5e2e..00000000 --- a/components/editor/outline/OutlineView.module.css +++ /dev/null @@ -1,28 +0,0 @@ -.outline_panel { - position: relative; - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - background-color: var(--main-bg); -} - -.outline_scroll { - position: relative; - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: clip; - scrollbar-gutter: stable; -} - -.outline_list { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; - max-width: 580px; - margin: 0 auto; - padding: 16px; - box-sizing: border-box; -} diff --git a/components/editor/outline/OutlineView.tsx b/components/editor/outline/OutlineView.tsx deleted file mode 100644 index ea245791..00000000 --- a/components/editor/outline/OutlineView.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ProjectContext } from "@src/context/ProjectContext"; -import { SplitSide, useViewContext } from "@src/context/ViewContext"; -import { BoardCardData, MAIN_SCREENPLAY_REF, OutlineItem } from "@src/lib/project/project-state"; -import { TransientScene } from "@src/lib/screenplay/scenes"; -import { focusOnPosition } from "@src/lib/screenplay/editor"; -import OutlineBlock, { DropPosition } from "./OutlineItem"; - -import styles from "./OutlineView.module.css"; - -/** Live-resolved display data for an outline block. */ -export type ResolvedOutline = { - title: string; - preview: string; - color?: string; - /** True when the referenced source element no longer exists. */ - missing: boolean; - /** Editor position of a resolved scene (for navigation). */ - position?: number; -}; - -const PREVIEW_MAX = 80; - -const truncate = (text: string) => { - const clean = text.replace(/\s+/g, " ").trim(); - return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; -}; - -const parseCards = (raw: unknown): BoardCardData[] => { - if (!raw) return []; - try { - return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); - } catch { - return []; - } -}; - -const OutlineView = ({ isVisible }: { isVisible: boolean }) => { - const { outline, repository, scenes, editor, documentEditor, setBoardFocusCardId } = - useContext(ProjectContext); - const { isSplit, primaryPanel, setSidePanel, setFocusedSide, setSideDocument } = useViewContext(); - const projectState = repository?.getState(); - - const [draggingId, setDraggingId] = useState(null); - const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); - - // Children of a parent (null = root), sorted by fractional `order`. - const childrenOf = useCallback( - (parentId: string | null): OutlineItem[] => - Object.values(outline) - .filter((n) => (n.parentId ?? null) === (parentId ?? null)) - .sort((a, b) => a.order - b.order), - [outline], - ); - - const roots = useMemo(() => childrenOf(null), [childrenOf]); - - const appendOrder = useCallback( - (parentId: string | null, excludeId?: string) => { - const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); - return kids.length ? kids[kids.length - 1].order + 1 : 0; - }, - [childrenOf], - ); - - // ---- Live resolution of references ---- - - // Distinct source documents referenced by current blocks. - const cardBoardIds = useMemo( - () => [...new Set(Object.values(outline).filter((i) => i.source === "card").map((i) => i.refDocId))], - [outline], - ); - const editorDocIds = useMemo( - () => - [ - ...new Set( - Object.values(outline) - .filter((i) => i.source === "scene" && i.refDocId !== MAIN_SCREENPLAY_REF) - .map((i) => i.refDocId), - ), - ], - [outline], - ); - - // Bump a counter whenever a referenced board / editor-doc changes, so the - // resolver recomputes. Main-screenplay scenes come from `scenes` (reactive). - const [sourceVersion, setSourceVersion] = useState(0); - const cardKey = cardBoardIds.join(","); - const editorKey = editorDocIds.join(","); - useEffect(() => { - if (!projectState) return; - const bump = () => setSourceVersion((v) => v + 1); - const unsubs: (() => void)[] = []; - for (const id of cardBoardIds) { - const map = projectState.boardData(id); - map.observe(bump); - unsubs.push(() => map.unobserve(bump)); - } - for (const id of editorDocIds) { - const frag = projectState.documentFragment(id); - frag.observe(bump); - unsubs.push(() => frag.unobserve(bump)); - } - return () => unsubs.forEach((u) => u()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectState, cardKey, editorKey]); - - const resolved = useMemo>(() => { - const out: Record = {}; - - // Lookups for the main screenplay scenes (already live via context). - const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); - - // Parse each referenced editor document's scenes once. - const editorScenes = new Map>(); - for (const id of editorDocIds) { - const list = repository?.getEditorDocumentScenes(id) ?? []; - editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); - } - - // Parse each referenced board's cards once. - const boardCards = new Map>(); - for (const id of cardBoardIds) { - const cards = parseCards(projectState?.boardData(id).get("cards")); - boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); - } - - for (const item of Object.values(outline)) { - if (item.source === "card") { - const card = boardCards.get(item.refDocId)?.get(item.refId); - out[item.id] = card - ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } - : { title: item.title, preview: item.preview, color: item.color, missing: true }; - } else { - const scene = - item.refDocId === MAIN_SCREENPLAY_REF - ? mainScenes.get(item.refId) - : editorScenes.get(item.refDocId)?.get(item.refId); - if (scene) { - const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; - const color = "color" in scene ? (scene.color as string | undefined) : undefined; - out[item.id] = { - title: scene.title, - preview: truncate(synopsis || scene.preview), - color, - missing: false, - position: scene.position, - }; - } else { - out[item.id] = { title: item.title, preview: item.preview, color: item.color, missing: true }; - } - } - } - return out; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [outline, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); - - // Keep each block's cached snapshot fresh so the "unlinked" fallback shows - // the last-known title/preview/color. Only writes when a resolved value - // actually changed (and the source still exists), so it settles quickly. - // Gated on visibility to avoid background writes for an unseen panel. - useEffect(() => { - if (!repository || !isVisible) return; - for (const item of Object.values(outline)) { - const r = resolved[item.id]; - if (!r || r.missing) continue; - if (r.title !== item.title || r.preview !== item.preview || (r.color ?? undefined) !== (item.color ?? undefined)) { - repository.refreshOutlineSnapshot(item.id, { title: r.title, preview: r.preview, color: r.color }); - } - } - }, [repository, outline, resolved, isVisible]); - - // ---- Navigation ---- - - // Best-effort focus of a freshly opened document editor needs the latest - // instance, so track it in a ref. - const documentEditorRef = useRef(documentEditor); - documentEditorRef.current = documentEditor; - - const navigate = useCallback( - (item: OutlineItem) => { - const r = resolved[item.id]; - if (!r || r.missing) return; - - // Open in the panel next to the Outline, leaving the Outline in place. - // When unsplit there's only one panel, so the target takes it over. - const targetSide: SplitSide = isSplit ? (primaryPanel === "outline" ? "secondary" : "primary") : "primary"; - - if (item.source === "card") { - setSideDocument(targetSide, item.refDocId, "board"); - setBoardFocusCardId(item.refId); - return; - } - - // Scene - if (item.refDocId === MAIN_SCREENPLAY_REF) { - setSidePanel(targetSide, "screenplay"); - setFocusedSide(targetSide); - if (editor && r.position !== undefined) focusOnPosition(editor, r.position); - return; - } - - // Scene inside an editor document. - setSideDocument(targetSide, item.refDocId, "document"); - if (r.position !== undefined) { - const pos = r.position; - window.setTimeout(() => { - const ed = documentEditorRef.current; - if (ed) focusOnPosition(ed, pos); - }, 350); - } - }, - [resolved, editor, isSplit, primaryPanel, setSideDocument, setSidePanel, setFocusedSide, setBoardFocusCardId], - ); - - const remove = useCallback( - (id: string) => { - repository?.deleteOutlineItem(id); - }, - [repository], - ); - - // ---- Drag & drop (mirrors DocumentTreeSidebarView) ---- - - const onDragStart = useCallback((id: string) => setDraggingId(id), []); - - const onDragOverNode = useCallback((id: string, pos: DropPosition) => { - setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); - }, []); - - const resetDrag = useCallback(() => { - setDraggingId(null); - setDropTarget(null); - }, []); - - const onDropNode = useCallback( - (targetId: string) => { - const dragId = draggingId; - const target = outline[targetId]; - const pos = dropTarget?.pos; - resetDrag(); - if (!repository || !dragId || !target || !pos || dragId === targetId) return; - - if (pos === "into") { - repository.moveOutlineItem(dragId, target.id, appendOrder(target.id, dragId)); - return; - } - - const parentId = target.parentId ?? null; - const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); - const idx = siblings.findIndex((n) => n.id === targetId); - let order: number; - if (pos === "before") { - const prev = siblings[idx - 1]; - order = prev ? (prev.order + target.order) / 2 : target.order - 1; - } else { - const next = siblings[idx + 1]; - order = next ? (target.order + next.order) / 2 : target.order + 1; - } - repository.moveOutlineItem(dragId, parentId, order); - }, - [draggingId, outline, dropTarget, repository, appendOrder, childrenOf, resetDrag], - ); - - const onDropRoot = useCallback(() => { - const dragId = draggingId; - resetDrag(); - if (!repository || !dragId) return; - repository.moveOutlineItem(dragId, null, appendOrder(null, dragId)); - }, [draggingId, repository, appendOrder, resetDrag]); - - return ( -
-
{ - if (!draggingId) return; - // Over empty space (not a block): clear the block indicator; - // dropping here still moves the item to the root level. - e.preventDefault(); - setDropTarget(null); - }} - onDrop={(e) => { - e.preventDefault(); - onDropRoot(); - }} - > -
- {roots.map((node) => ( - - ))} -
-
-
- ); -}; - -export default OutlineView; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 6341d7d3..cfd95929 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -125,16 +125,15 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { unomitSceneByUuid(editor, repository, scene.id); }; - const handleSendToOutline = () => { + const handleSendToTimeline = () => { if (!repository || !scene.id) return; - repository.addOutlineItem({ + repository.appendTimelineClip({ source: "scene", refDocId: MAIN_SCREENPLAY_REF, refId: scene.id, title: scene.title, preview: scene.synopsis || scene.preview, color: scene.color, - parentId: null, }); }; @@ -154,9 +153,9 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { {scene.id && ( )} editScenePopup(scene, userCtx)} /> @@ -497,7 +496,7 @@ export type EditorContextMenuProps = { spellError?: { word: string; from: number; to: number }; nodePos?: number; nodeClass?: string; - /** Present when the caret sits on a scene heading that can be sent to the Outline. */ + /** Present when the caret sits on a scene heading that can be sent to the Timeline. */ outlineScene?: { refDocId: string; refId: string; title: string }; /** Present on paginated screenplay editors: the top-level block under the * caret (`pos`) and whether it already forces a manual page break. */ @@ -515,15 +514,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { const { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak } = props; const hasSelection = from !== to; - const handleSendToOutline = () => { + const handleSendToTimeline = () => { if (!repository || !outlineScene) return; - repository.addOutlineItem({ + repository.appendTimelineClip({ source: "scene", refDocId: outlineScene.refDocId, refId: outlineScene.refId, title: outlineScene.title, preview: "", - parentId: null, }); updateContextMenu(undefined); }; @@ -745,11 +743,11 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} - {/* Send a scene heading to the Outline */} + {/* Send a scene heading to the Timeline */} {outlineScene && !isReadOnly && ( <> - + )} diff --git a/components/editor/timeline/TimelinePanel.module.css b/components/editor/timeline/TimelinePanel.module.css new file mode 100644 index 00000000..d25aef85 --- /dev/null +++ b/components/editor/timeline/TimelinePanel.module.css @@ -0,0 +1,656 @@ +.timeline { + display: flex; + flex-direction: column; + width: 100%; + max-height: 45vh; + background-color: var(--main-bg); + border-bottom: 1px solid var(--separator); + box-sizing: border-box; + overflow: hidden; + flex-shrink: 0; + /* Chrome (ruler labels, clip titles + durations, etc.) is not selectable; + * the editable text inputs re-enable selection below. */ + user-select: none; + -webkit-user-select: none; +} + +.timeline input { + user-select: text; + -webkit-user-select: text; +} + +/* ---- Toolbar ---- */ + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + flex-shrink: 0; + padding: 0 10px; + border-bottom: 1px solid var(--separator); +} + +.toolbar_title { + font-size: 13px; + font-weight: 600; + color: var(--primary-text); +} + +.toolbar_spacer { + flex: 1; +} + +.tool_btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; + border: none; + border-radius: 6px; + background-color: var(--secondary); + color: var(--secondary-text); + font-size: 12px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.tool_btn:hover { + background-color: var(--secondary-hover); +} + +.tool_btn_label { + white-space: nowrap; +} + +.zoom { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.icon_btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px; + border: none; + border-radius: 6px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.12s ease; +} + +.icon_btn:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.icon_btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* Feature length */ +.length_anchor { + position: relative; + display: inline-flex; +} + +.length_popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--main-bg); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16); +} + +.length_label { + font-size: 11px; + color: var(--secondary-text); +} + +.length_field { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.length_input { + width: 72px; + padding: 5px 8px; + border: 1px solid var(--separator); + border-radius: 6px; + background-color: var(--secondary); + color: var(--primary-text); + font-size: 13px; + outline: none; +} + +.length_input:focus { + border-color: var(--tertiary); +} + +.length_unit { + font-size: 12px; + color: var(--secondary-text); +} + +/* Scale slider */ +.zoom_icon { + color: var(--secondary-text); + flex-shrink: 0; +} + +.zoom_slider { + width: 96px; + cursor: pointer; + accent-color: var(--tertiary); +} + +/* ---- Custom horizontal scrollbar ---- */ + +/* A dedicated, always-present horizontal scrollbar sitting just above the + * tracks. Native macOS overlay scrollbars take no space and auto-hide, which + * left no reliable way to scroll a zoomed-in timeline — so we render our own. */ +.hscroll { + flex-shrink: 0; + height: 16px; + padding: 4px 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--separator); + background-color: var(--main-bg); +} + +.hscroll_track { + position: relative; + width: 100%; + height: 8px; + border-radius: 4px; + background-color: var(--secondary); +} + +.hscroll_thumb { + position: absolute; + top: 0; + height: 8px; + min-width: 28px; + border-radius: 4px; + background-color: var(--secondary-text); + cursor: grab; + touch-action: none; +} + +.hscroll_thumb:hover { + background-color: var(--primary-text); +} + +.hscroll_thumb:active { + cursor: grabbing; +} + +/* ---- Scroll area ---- */ + +/* Layer tracks — scrolls vertically (layers) and horizontally (time). The + * native horizontal scrollbar is hidden; the custom .hscroll above drives the + * horizontal position instead. */ +.tracks_scroll { + position: relative; + flex: 1; + min-height: 0; + overflow-x: scroll; + overflow-y: auto; +} + +.tracks_scroll::-webkit-scrollbar:horizontal { + height: 0; +} + +.grid { + position: relative; + min-width: 100%; +} + +.rows { + position: relative; + display: flex; + flex-direction: column; +} + +.row { + position: relative; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--separator); +} + +/* Drop indicator: highlight the whole hovered lane (track + its sticky label) so + * the drop target is obvious. A thin edge line additionally marks a reorder + * (before/after) vs a nest (into). Driven by the row's data-drop attribute + * (rendered from React state). Uses --secondary-text (a mid-tone in every + * theme) so the tint is clearly visible over the neutral background. */ +.row[data-drop], +.row[data-drop] .label { + background-color: color-mix(in srgb, var(--secondary-text) 45%, var(--main-bg)); +} + +.row[data-drop="before"]::before, +.row[data-drop="after"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 3px; + background-color: var(--secondary-text); + z-index: 4; + pointer-events: none; +} + +.row[data-drop="before"]::before { + top: -1px; +} + +.row[data-drop="after"]::after { + bottom: -1px; +} + +/* Left name column — pinned while the tracks scroll horizontally. Doubles as the + * drag handle: pressing and moving it reorders/nests the lane. */ +.label { + position: sticky; + left: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + padding: 0 6px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); + box-sizing: border-box; + cursor: grab; + touch-action: none; +} + +.label:active { + cursor: grabbing; +} + +/* While renaming, the label is a text field, not a drag handle. */ +.label_editing, +.label_editing:active { + cursor: default; + touch-action: auto; +} + +.label_dragging { + opacity: 0.4; +} + +/* Per-depth inset that nests child lanes under their parent. */ +.label_indent { + flex-shrink: 0; +} + +/* Fold chevron (only present when a layer has children). */ +.fold_btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 4px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.12s ease; +} + +.fold_btn:hover { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +/* Keeps leaf lanes' names aligned with the ones that have a chevron. */ +.fold_spacer { + flex-shrink: 0; + width: 18px; +} + +.label_input { + flex: 1; + min-width: 0; + padding: 4px 6px; + border: 1px solid transparent; + border-radius: 5px; + background: none; + color: var(--primary-text); + font-size: 12px; + font-weight: 500; + outline: none; + transition: border-color 0.12s ease, background-color 0.12s ease; +} + +.label_input:hover { + background-color: var(--secondary); +} + +.label_input:focus { + border-color: var(--tertiary); + background-color: var(--secondary); +} + +/* Read-only name display (swaps to .label_input on double-click / rename). */ +.label_name { + flex: 1; + min-width: 0; + padding: 4px 6px; + font-size: 12px; + font-weight: 500; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Track — clip container within a row. */ +.track { + position: relative; + flex-shrink: 0; +} + +/* ---- Clip ---- */ + +.clip { + position: absolute; + top: 6px; + bottom: 6px; + display: flex; + align-items: stretch; + box-sizing: border-box; + border-radius: 6px; + border-left: 3px solid var(--card-color); + background-color: color-mix(in srgb, var(--card-color) 26%, var(--main-bg)); + cursor: grab; + overflow: hidden; + user-select: none; + touch-action: none; + transition: background-color 0.12s ease; +} + +.clip:hover { + background-color: color-mix(in srgb, var(--card-color) 38%, var(--main-bg)); +} + +.clip:active { + cursor: grabbing; +} + +.clip_missing { + opacity: 0.55; +} + +.clip_body { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + padding: 0 20px 0 10px; +} + +.clip_icon { + flex-shrink: 0; + color: var(--secondary-text); +} + +.clip_title { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--primary-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; +} + +.clip_unlinked { + flex-shrink: 0; + color: var(--secondary-text); +} + +.clip_duration { + flex-shrink: 0; + margin-left: auto; + padding-left: 6px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--secondary-text); + pointer-events: none; +} + +/* Resize handles at each edge. */ +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 8px; + z-index: 2; + cursor: ew-resize; +} + +.handle_l { + left: 0; +} + +.handle_r { + right: 0; +} + +.empty_hint { + position: absolute; + top: 14px; + z-index: 1; + font-size: 13px; + color: var(--secondary-text); + pointer-events: none; +} + +/* Playhead — vertical marker across every lane at the selected time. Sits above + * clips but below the sticky label (z-index 2), so it hides under the label when + * scrolled off the left edge. */ +.playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + z-index: 1; + pointer-events: none; +} + +/* ---- Scene overview line (read-only band above the tracks) ---- */ + +.scene_row { + display: flex; + flex-direction: row; + flex-shrink: 0; + z-index: 3; + background-color: var(--main-bg); + border-top: 1px solid var(--separator); +} + +.scene_corner { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: 10px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); +} + +.scene_unit { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--secondary-text); +} + +/* Clips the band and lets it slide horizontally with the tracks. */ +.scene_viewport { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.scene_line { + position: absolute; + top: 0; + bottom: 0; + left: 0; + will-change: transform; +} + +/* One scene block. Purely informative — no pointer affordances. */ +.scene_seg { + position: absolute; + top: 4px; + bottom: 4px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 6px; + border-radius: 4px; + border-left: 3px solid var(--scene-color); + background-color: color-mix(in srgb, var(--scene-color) 22%, var(--main-bg)); + overflow: hidden; +} + +.scene_seg_title { + font-size: 11px; + font-weight: 600; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +/* Playhead marker across the scene band — extends the tracks' playhead over the + * scene overview so the cursor reads as one continuous line down the timeline. */ +.scene_playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + z-index: 2; + pointer-events: none; +} + +/* ---- Ruler (fixed row below the tracks) ---- */ + +.ruler_row { + display: flex; + flex-direction: row; + flex-shrink: 0; + z-index: 3; + background-color: var(--main-bg); + border-top: 1px solid var(--separator); +} + +.ruler_corner { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: 10px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); +} + +.ruler_unit { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--secondary-text); +} + +/* Clips the ruler track and lets it slide horizontally with the tracks. */ +.ruler_viewport { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; + cursor: pointer; +} + +/* Playhead marker on the ruler: a stem with a triangle head. */ +.ruler_playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + pointer-events: none; +} + +.ruler_playhead::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--tertiary); +} + +.ruler { + position: absolute; + top: 0; + bottom: 0; + left: 0; + will-change: transform; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid var(--separator); +} + +/* Faint eighth subdivisions between the labelled minute ticks. */ +.tick_eighth { + position: absolute; + bottom: 0; + height: 6px; + border-left: 1px solid color-mix(in srgb, var(--separator) 55%, transparent); +} + +.tick_label { + position: absolute; + left: 4px; + top: 5px; + font-size: 10px; + color: var(--secondary-text); + white-space: nowrap; +} diff --git a/components/editor/timeline/TimelinePanel.tsx b/components/editor/timeline/TimelinePanel.tsx new file mode 100644 index 00000000..9fe40e30 --- /dev/null +++ b/components/editor/timeline/TimelinePanel.tsx @@ -0,0 +1,1281 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +import { useViewContext } from "@src/context/ViewContext"; +import { BoardCardData, MAIN_SCREENPLAY_REF, TimelineClip, TimelineLayer } from "@src/lib/project/project-state"; +import { TransientScene } from "@src/lib/screenplay/scenes"; +import { focusOnPosition } from "@src/lib/screenplay/editor"; +import { join } from "@src/lib/utils/misc"; +import { ContextMenuItem } from "@components/utils/ContextMenu"; +import { + ChevronDown, + ChevronRight, + Clock, + Film, + Pencil, + Plus, + Trash2, + Unlink, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import styles from "./TimelinePanel.module.css"; + +/** Live-resolved display data for a clip. */ +type ResolvedClip = { + title: string; + preview: string; + color?: string; + /** True when the referenced source element no longer exists. */ + missing: boolean; + /** Editor position of a resolved scene (for navigation). */ + position?: number; +}; + +// Layout constants. +const TRACK_H = 46; // height of one layer lane, px +const LABEL_W = 150; // width of the left name column, px +const RULER_H = 26; // height of the bottom minute ruler, px +const SCENE_LINE_H = 26; // height of the read-only scene overview band, px +const TOOLBAR_H = 44; // height of the top toolbar, px +const SCROLLBAR_H = 16; // height of the custom horizontal scrollbar strip (.hscroll) +const INDENT = 14; // left inset per nesting level in the label column, px + +// Clip starts/widths snap to eighths of a minute (7.5s) for fine placement. +const EIGHTH = 1 / 8; // minutes per snap step +const MIN_DURATION = EIGHTH; // shortest clip +const SNAP = EIGHTH; // snap starts/widths to the eighth grid + +// Zoom levels expressed as minutes of runtime visible across the panel width. +// pixels-per-minute is derived from the measured panel width (below), so a given +// slider position shows the same span regardless of screen size. +const ZOOM_MINUTES = [120, 85, 60, 42, 30, 22, 16, 11, 7.5, 5]; +const DEFAULT_ZOOM_INDEX = 6; // ~16 minutes visible by default +// Assumed panel width until it's actually measured (avoids a 0-width first paint). +const FALLBACK_PANEL_WIDTH = 1000; + +const DEFAULT_FEATURE_LENGTH = 90; // minutes, mirrors the repository default + +const PREVIEW_MAX = 80; + +const truncate = (text: string) => { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; +}; + +const parseCards = (raw: unknown): BoardCardData[] => { + if (!raw) return []; + try { + return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); + } catch { + return []; + } +}; + +const snap = (v: number) => Math.round(v / SNAP) * SNAP; + +// Suppress text selection (e.g. layer names) for the duration of a pointer drag. +const lockSelection = () => { + document.body.style.userSelect = "none"; + document.body.style.setProperty("-webkit-user-select", "none"); +}; +const unlockSelection = () => { + document.body.style.userSelect = ""; + document.body.style.removeProperty("-webkit-user-select"); +}; + +// Magnetic snap tolerance to other clips' edges, in pixels. +const SNAP_PX = 7; + +/** Nearest candidate to `value` within `tol` minutes, or null if none is close. */ +const nearestCandidate = (value: number, candidates: number[], tol: number): number | null => { + let best: number | null = null; + let bestDist = tol; + for (const c of candidates) { + const dist = Math.abs(c - value); + if (dist <= bestDist) { + bestDist = dist; + best = c; + } + } + return best; +}; + +/** Ruler tick label in minutes, rolling up to hours past 60. */ +const formatTick = (m: number) => { + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const r = m % 60; + return r ? `${h}h${r}` : `${h}h`; +}; + +/** Clip duration readout as `m:ss` of screen time. */ +const formatDuration = (minutes: number) => { + const secs = Math.round(minutes * 60); + const mm = Math.floor(secs / 60); + const ss = secs % 60; + return `${mm}:${ss.toString().padStart(2, "0")}`; +}; + +/** Where a dragged layer would land relative to the layer it's dropped on. */ +type LayerDropPos = "before" | "into" | "after"; + +/** In-flight drag: a live placement preview committed to Yjs on pointer up. */ +type DragState = { + clip: TimelineClip; + mode: "move" | "resize-l" | "resize-r"; + startX: number; + startY: number; + origStart: number; + origDuration: number; + moved: boolean; + /** Edges (starts + ends, minutes) of every other clip, for magnetic snapping. */ + snapCandidates: number[]; +}; + +/** Other clips sharing a layer, sorted by start — the obstacles a drag/resize + * must not overlap. */ +const sameLayerClips = (clips: Record, layerId: string, excludeId: string) => + Object.values(clips) + .filter((c) => c.layerId === layerId && c.id !== excludeId) + .sort((a, b) => a.start - b.start); + +/** Nearest start to `desired` for a clip of `dur` that fits a free gap between + * `others` (so two clips on one layer never overlap). */ +const fitStart = (others: TimelineClip[], dur: number, desired: number): number => { + const gaps: [number, number][] = []; + let cursor = 0; + for (const o of others) { + if (o.start > cursor) gaps.push([cursor, o.start]); + cursor = Math.max(cursor, o.start + o.duration); + } + gaps.push([cursor, Infinity]); // trailing gap always fits + let best = Math.max(0, desired); + let bestDist = Infinity; + for (const [lo, hi] of gaps) { + if (hi - lo < dur - 1e-9) continue; + const s = Math.max(lo, Math.min(desired, hi - dur)); + const dist = Math.abs(s - desired); + if (dist < bestDist) { + bestDist = dist; + best = s; + } + } + return best; +}; + +const TimelinePanel = () => { + const t = useTranslations("timeline"); + const { + repository, + timelineLayers, + timelineClips, + scenes, + editor, + } = useContext(ProjectContext); + const { focusedSide, setSidePanel, setFocusedSide, setTimelineOpen } = useViewContext(); + const { updateContextMenu } = useContext(UserContext); + const projectState = repository?.getState(); + + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + // Visible width of the track area (panel minus the sticky label column). Each + // zoom level fits a fixed number of minutes into it. + const [trackViewportW, setTrackViewportW] = useState(0); + const pxPerMin = (trackViewportW > 0 ? trackViewportW : FALLBACK_PANEL_WIDTH) / ZOOM_MINUTES[zoomIndex]; + + // Playhead position in minutes (null until the user picks a spot on the ruler + // or an empty part of a track). + const [playhead, setPlayhead] = useState(null); + + // Feature length (minutes) lives in project metadata so it persists and syncs. + const [featureLength, setFeatureLength] = useState( + () => repository?.getFeatureLength() ?? DEFAULT_FEATURE_LENGTH, + ); + const [lengthEditorOpen, setLengthEditorOpen] = useState(false); + const lengthAnchorRef = useRef(null); + useEffect(() => { + if (!repository) return; + setFeatureLength(repository.getFeatureLength()); + return repository.observeMetadata((meta) => { + if (meta.featureLength !== undefined) setFeatureLength(meta.featureLength); + }); + }, [repository]); + useEffect(() => { + if (!lengthEditorOpen) return; + const onDown = (e: MouseEvent) => { + if (lengthAnchorRef.current && !lengthAnchorRef.current.contains(e.target as Node)) { + setLengthEditorOpen(false); + } + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [lengthEditorOpen]); + + // Folded layers (local, like the document tree's expand state). + const [collapsed, setCollapsed] = useState>(() => new Set()); + const toggleCollapse = useCallback((id: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Children of a layer (null = roots), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null) => + Object.values(timelineLayers) + .filter((l) => (l.parentId ?? null) === parentId) + .sort((a, b) => a.order - b.order), + [timelineLayers], + ); + + const appendOrder = useCallback( + (parentId: string | null, excludeId?: string) => { + const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); + return kids.length ? kids[kids.length - 1].order + 1 : 0; + }, + [childrenOf], + ); + + /** Flattened, depth-annotated lanes in display order, skipping folded subtrees. */ + const visibleLayers = useMemo(() => { + const out: { layer: TimelineLayer; depth: number; hasChildren: boolean }[] = []; + const walk = (parentId: string | null, depth: number) => { + for (const layer of childrenOf(parentId)) { + const kids = childrenOf(layer.id); + out.push({ layer, depth, hasChildren: kids.length > 0 }); + if (kids.length && !collapsed.has(layer.id)) walk(layer.id, depth + 1); + } + }; + walk(null, 0); + return out; + }, [childrenOf, collapsed]); + + // ---- Layer drag & drop (pointer-based; see onLayerPointerDown below) ---- + // Pointer-based rather than native HTML5 drag so the hover indicator repaints + // reliably (WebKit does not repaint DOM changes made during a native drag). + // Dropping over a lane's top/bottom quarter reorders it as a sibling; over the + // middle nests it under the target. + const [draggingLayerId, setDraggingLayerId] = useState(null); + + // Inline rename: the layer whose name is being edited (via double-click or the + // right-click menu). Its swaps to an , which the effect focuses. + const [editingLayerId, setEditingLayerId] = useState(null); + const editInputRef = useRef(null); + useEffect(() => { + if (editingLayerId && editInputRef.current) { + editInputRef.current.focus(); + editInputRef.current.select(); + } + }, [editingLayerId]); + + const commitLayerMove = useCallback( + (dragId: string, targetId: string, pos: LayerDropPos) => { + const target = timelineLayers[targetId]; + if (!repository || !target || dragId === targetId) return; + + if (pos === "into") { + repository.moveTimelineLayer(dragId, target.id, appendOrder(target.id, dragId)); + setCollapsed((c) => { + if (!c.has(target.id)) return c; + const next = new Set(c); + next.delete(target.id); // reveal the newly nested lane + return next; + }); + return; + } + + const parentId = target.parentId ?? null; + const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); + const idx = siblings.findIndex((n) => n.id === targetId); + let order: number; + if (pos === "before") { + const prev = siblings[idx - 1]; + order = prev ? (prev.order + target.order) / 2 : target.order - 1; + } else { + const next = siblings[idx + 1]; + order = next ? (target.order + next.order) / 2 : target.order + 1; + } + repository.moveTimelineLayer(dragId, parentId, order); + }, + [timelineLayers, childrenOf, appendOrder, repository], + ); + + // Seed the two default lanes the first time the timeline is opened empty. + const seededRef = useRef(false); + useEffect(() => { + if (seededRef.current || !repository) return; + if (Object.keys(timelineLayers).length === 0) { + seededRef.current = true; + repository.ensureTimelineLayers(2, (i) => `${t("layer")} ${i + 1}`); + } + }, [repository, timelineLayers, t]); + + // ---- Live resolution of clip references (mirrors the old Outline resolver) ---- + + const cardBoardIds = useMemo( + () => [...new Set(Object.values(timelineClips).filter((c) => c.source === "card").map((c) => c.refDocId))], + [timelineClips], + ); + const editorDocIds = useMemo( + () => [ + ...new Set( + Object.values(timelineClips) + .filter((c) => c.source === "scene" && c.refDocId !== MAIN_SCREENPLAY_REF) + .map((c) => c.refDocId), + ), + ], + [timelineClips], + ); + + const [sourceVersion, setSourceVersion] = useState(0); + const cardKey = cardBoardIds.join(","); + const editorKey = editorDocIds.join(","); + useEffect(() => { + if (!projectState) return; + const bump = () => setSourceVersion((v) => v + 1); + const unsubs: (() => void)[] = []; + for (const id of cardBoardIds) { + const map = projectState.boardData(id); + map.observe(bump); + unsubs.push(() => map.unobserve(bump)); + } + for (const id of editorDocIds) { + const frag = projectState.documentFragment(id); + frag.observe(bump); + unsubs.push(() => frag.unobserve(bump)); + } + return () => unsubs.forEach((u) => u()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectState, cardKey, editorKey]); + + const resolved = useMemo>(() => { + const out: Record = {}; + const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); + + const editorScenes = new Map>(); + for (const id of editorDocIds) { + const list = repository?.getEditorDocumentScenes(id) ?? []; + editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); + } + + const boardCards = new Map>(); + for (const id of cardBoardIds) { + const cards = parseCards(projectState?.boardData(id).get("cards")); + boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); + } + + for (const clip of Object.values(timelineClips)) { + if (clip.source === "card") { + const card = boardCards.get(clip.refDocId)?.get(clip.refId); + out[clip.id] = card + ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } + : { title: clip.title, preview: clip.preview, color: clip.color, missing: true }; + } else { + const scene = + clip.refDocId === MAIN_SCREENPLAY_REF + ? mainScenes.get(clip.refId) + : editorScenes.get(clip.refDocId)?.get(clip.refId); + if (scene) { + const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; + const color = "color" in scene ? (scene.color as string | undefined) : undefined; + out[clip.id] = { + title: scene.title, + preview: truncate(synopsis || scene.preview), + color, + missing: false, + position: scene.position, + }; + } else { + out[clip.id] = { title: clip.title, preview: clip.preview, color: clip.color, missing: true }; + } + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineClips, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); + + // Keep each clip's cached snapshot fresh so the "unlinked" fallback shows the + // last-known title/preview/color. + useEffect(() => { + if (!repository) return; + for (const clip of Object.values(timelineClips)) { + const r = resolved[clip.id]; + if (!r || r.missing) continue; + if ( + r.title !== clip.title || + r.preview !== clip.preview || + (r.color ?? undefined) !== (clip.color ?? undefined) + ) { + repository.refreshTimelineClipSnapshot(clip.id, { title: r.title, preview: r.preview, color: r.color }); + } + } + }, [repository, timelineClips, resolved]); + + // ---- Navigation ---- + + // Latest screenplay editor, read lazily so a deferred focus (after the + // screenplay panel mounts) never uses a stale/null instance. + const editorRef = useRef(editor); + editorRef.current = editor; + + // Clicking the timeline scrolls the screenplay editor to the page matching + // the clicked time — one minute of screen time ≈ one script page. It brings + // the screenplay into view first (but never opens boards). + const navigate = useCallback( + (minutes: number) => { + setSidePanel(focusedSide, "screenplay"); + setFocusedSide(focusedSide); + + const page = Math.max(1, Math.round(minutes)); // 1 min ≈ 1 page + + // The screenplay editor may still be mounting after setSidePanel; + // retry briefly until it's available. + const focus = (attempt: number) => { + const ed = editorRef.current; + if (!ed) { + if (attempt < 12) window.setTimeout(() => focus(attempt + 1), 40); + return; + } + // Each page's first node carries a pagination start class, one per + // page in document order — so the Nth marks the top of page N. + const starts = ed.view.dom.querySelectorAll( + ".pagination-doc-start, .pagination-break-start", + ); + if (starts.length > 0) { + const el = starts[Math.min(starts.length - 1, page - 1)]; + const pos = ed.view.posAtDOM(el, 0); + if (pos >= 0) { + focusOnPosition(ed, pos); + return; + } + } + // Fallback (pagination markers absent): proportional content scroll. + const size = ed.state.doc.content.size; + const fraction = featureLength > 0 ? Math.min(1, Math.max(0, minutes / featureLength)) : 0; + focusOnPosition(ed, Math.max(1, Math.min(size - 1, Math.round(fraction * size)))); + }; + focus(0); + }, + [focusedSide, setSidePanel, setFocusedSide, featureLength], + ); + + // ---- Timeline extent + ruler ---- + + const maxEnd = useMemo(() => { + let end = 0; + for (const c of Object.values(timelineClips)) end = Math.max(end, c.start + c.duration); + return end; + }, [timelineClips]); + + // Ticks spaced so labels never crowd (aim for >= 48px per major tick). + const tickStep = useMemo(() => { + const candidates = [1, 2, 5, 10, 15, 30, 60, 120]; + return candidates.find((s) => s * pxPerMin >= 48) ?? 120; + }, [pxPerMin]); + + // Span the whole feature, but always keep every clip in view. + const totalMinutes = useMemo(() => { + const min = Math.max(30, featureLength, Math.ceil(maxEnd) + 2); + return Math.ceil(min / tickStep) * tickStep; + }, [maxEnd, featureLength, tickStep]); + + const trackWidth = totalMinutes * pxPerMin; + + const ticks = useMemo(() => { + const out: number[] = []; + for (let m = 0; m <= totalMinutes; m += tickStep) out.push(m); + return out; + }, [totalMinutes, tickStep]); + + // Faint eighth-of-a-page gridlines, shown once an eighth is wide enough. + const eighthTicks = useMemo(() => { + if (EIGHTH * pxPerMin < 7) return []; + const out: number[] = []; + for (let m = 0; m <= totalMinutes + 1e-6; m += EIGHTH) out.push(m); + return out; + }, [totalMinutes, pxPerMin]); + + // ---- Scene overview line ---- + // A read-only band above the tracks showing every scene currently in the + // screenplay, laid out proportionally to its length across the feature + // runtime (1 page ≈ 1 minute). `scenes` comes from the ProjectContext, which + // recomputes them on the debounced screenplay callback — so this reflects the + // settled scene list, not every keystroke. Purely informative: no drag/edit. + const sceneSegments = useMemo(() => { + const items = scenes.filter((s) => s.nextPosition > s.position); + if (items.length === 0) return []; + const origin = items[0].position; + const total = items[items.length - 1].nextPosition - origin; + if (total <= 0) return []; + const spanWidth = featureLength * pxPerMin; + return items.map((s, i) => { + const left = ((s.position - origin) / total) * spanWidth; + const right = ((s.nextPosition - origin) / total) * spanWidth; + return { + key: s.id ?? `scene-${i}`, + title: s.title || t("untitled"), + color: s.color, + left, + // Leave a 2px seam between neighbours so adjacent scenes read as + // distinct blocks rather than one continuous bar. + width: Math.max(2, right - left - 2), + }; + }); + }, [scenes, featureLength, pxPerMin, t]); + + // ---- Ruler sync ---- + // The ruler lives outside the vertically-scrolling tracks area so it stays + // fixed while layers scroll. It follows the tracks' horizontal scroll via a + // transform so the two stay aligned. + const tracksScrollRef = useRef(null); + const rulerRef = useRef(null); + const sceneLineRef = useRef(null); + + // Custom horizontal scrollbar geometry (fraction of the content in view and + // the current scroll offset, both 0..1). Native macOS overlay scrollbars + // hide themselves and take no space, so we render our own bar instead. + const hTrackRef = useRef(null); + const [hbar, setHbar] = useState({ left: 0, ratio: 1 }); + const updateHbar = useCallback(() => { + const el = tracksScrollRef.current; + if (!el) return; + const { scrollLeft, clientWidth, scrollWidth } = el; + setHbar({ + left: scrollWidth > 0 ? scrollLeft / scrollWidth : 0, + ratio: scrollWidth > 0 ? Math.min(1, clientWidth / scrollWidth) : 1, + }); + }, []); + + const syncRuler = useCallback(() => { + if (tracksScrollRef.current) { + const offset = `translateX(${-tracksScrollRef.current.scrollLeft}px)`; + if (rulerRef.current) rulerRef.current.style.transform = offset; + if (sceneLineRef.current) sceneLineRef.current.style.transform = offset; + } + updateHbar(); + }, [updateHbar]); + // Re-align after zoom changes the track width. + useEffect(syncRuler, [syncRuler, trackWidth]); + // Track the panel's visible track width (drives pixels-per-minute) and keep + // the scrollbar sized as it resizes. + useEffect(() => { + const el = tracksScrollRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + setTrackViewportW(el.clientWidth - LABEL_W); + updateHbar(); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [updateHbar]); + + // Drag the custom scrollbar thumb to scroll the tracks horizontally. + const onHThumbPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const el = tracksScrollRef.current; + const track = hTrackRef.current; + if (!el || !track) return; + const startX = e.clientX; + const startLeft = el.scrollLeft; + const range = el.scrollWidth - el.clientWidth; // scrollable px + const thumbTravel = track.clientWidth * (1 - Math.min(1, el.clientWidth / el.scrollWidth)); + const onMove = (ev: PointerEvent) => { + if (thumbTravel <= 0) return; + el.scrollLeft = startLeft + ((ev.clientX - startX) * range) / thumbTravel; + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, []); + + // Move the playhead to the time under a click on the ruler or an empty track, + // and scroll the screenplay to the approximate matching spot. + const setPlayheadFromClientX = useCallback( + (clientX: number) => { + const el = tracksScrollRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = clientX - rect.left - LABEL_W + el.scrollLeft; + const minutes = Math.max(0, Math.min(totalMinutes, x / pxPerMin)); + setPlayhead(minutes); + navigate(minutes); + }, + [totalMinutes, pxPerMin, navigate], + ); + + // ---- Drag & resize ---- + + const tracksRef = useRef(null); + const dragRef = useRef(null); + // Live placement of the clip being dragged; committed to Yjs on pointer up. + // Mirrored in a ref so the pointer-up commit never reads a stale (one move + // behind) value from React state. + // A drag previews one clip (move / plain resize) or two (a roll edit on a + // shared border, where both adjacent clips update together). + type Placement = Pick; + const [preview, setPreviewState] = useState(null); + const previewRef = useRef(null); + const setPreview = useCallback((p: Placement[] | null) => { + previewRef.current = p; + setPreviewState(p); + }, []); + + const layerAtY = useCallback( + (clientY: number): string => { + const el = tracksRef.current; + if (!el || visibleLayers.length === 0) return visibleLayers[0]?.layer.id ?? ""; + const rect = el.getBoundingClientRect(); + const i = Math.floor((clientY - rect.top) / TRACK_H); + const clamped = Math.max(0, Math.min(visibleLayers.length - 1, i)); + return visibleLayers[clamped].layer.id; + }, + [visibleLayers], + ); + + // Window listeners for the active clip drag. Stored so an unmount mid-drag + // can detach them. + const clipDragListenersRef = useRef<{ + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; + cancel: (e: PointerEvent) => void; + } | null>(null); + + const onClipPointerDown = useCallback( + (e: React.PointerEvent, clip: TimelineClip, mode: DragState["mode"]) => { + if (e.button !== 0) return; + e.stopPropagation(); + // Snap targets: the origin plus every other clip's start and end, so + // edges line up with clips on any layer. + const candidates = new Set([0]); + for (const c of Object.values(timelineClips)) { + if (c.id === clip.id) continue; + candidates.add(c.start); + candidates.add(c.start + c.duration); + } + lockSelection(); + dragRef.current = { + clip, + mode, + startX: e.clientX, + startY: e.clientY, + origStart: clip.start, + origDuration: clip.duration, + moved: false, + snapCandidates: [...candidates], + }; + setPreview([{ id: clip.id, layerId: clip.layerId, start: clip.start, duration: clip.duration }]); + + // Drive the drag from window listeners rather than the clip element + + // pointer capture: when the clip re-parents to another layer's track + // mid-drag, an element-scoped capture is lost and pointerup never + // fires, leaving the clip "stuck" to the cursor. Window listeners are + // immune to that re-parenting. + const onMove = (ev: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const dxMin = (ev.clientX - d.startX) / pxPerMin; + // Slightly forgiving threshold so a trackpad click (which can + // jitter a few px) still counts as a tap, not a drag. + if (!d.moved && (Math.abs(ev.clientX - d.startX) > 6 || Math.abs(ev.clientY - d.startY) > 6)) { + d.moved = true; + } + const tol = SNAP_PX / pxPerMin; + const cands = d.snapCandidates; + + if (d.mode === "move") { + const rawStart = Math.max(0, d.origStart + dxMin); + const rawEnd = rawStart + d.origDuration; + const snapStart = nearestCandidate(rawStart, cands, tol); + const snapEnd = nearestCandidate(rawEnd, cands, tol); + let start: number; + if (snapStart !== null && (snapEnd === null || Math.abs(snapStart - rawStart) <= Math.abs(snapEnd - rawEnd))) { + start = snapStart; + } else if (snapEnd !== null) { + start = snapEnd - d.origDuration; + } else { + start = snap(rawStart); + } + start = Math.max(0, start); + // Keep the clip inside a free gap on the target layer so two + // clips of the same layer never overlap. + const targetLayer = layerAtY(ev.clientY); + start = fitStart(sameLayerClips(timelineClips, targetLayer, d.clip.id), d.origDuration, start); + setPreview([{ id: d.clip.id, layerId: targetLayer, start, duration: d.origDuration }]); + } else if (d.mode === "resize-r") { + const others = sameLayerClips(timelineClips, d.clip.layerId, d.clip.id); + const origEnd = d.origStart + d.origDuration; + const rawEnd = d.origStart + Math.max(MIN_DURATION, d.origDuration + dxMin); + const snapEnd = nearestCandidate(rawEnd, cands, tol); + let border = snapEnd !== null ? snapEnd : d.origStart + snap(rawEnd - d.origStart); + // If a clip is flush against this border, roll it: move both edges. + const neighbor = others.find((o) => Math.abs(o.start - origEnd) < 1e-6); + if (neighbor) { + const nEnd = neighbor.start + neighbor.duration; + border = Math.max(d.origStart + MIN_DURATION, Math.min(nEnd - MIN_DURATION, border)); + setPreview([ + { id: d.clip.id, layerId: d.clip.layerId, start: d.origStart, duration: border - d.origStart }, + { id: neighbor.id, layerId: neighbor.layerId, start: border, duration: nEnd - border }, + ]); + } else { + const rightLimit = others.reduce((m, o) => (o.start >= d.origStart ? Math.min(m, o.start) : m), Infinity); + border = Math.min(border, rightLimit); + const duration = Math.max(MIN_DURATION, border - d.origStart); + setPreview([{ id: d.clip.id, layerId: d.clip.layerId, start: d.origStart, duration }]); + } + } else { + const others = sameLayerClips(timelineClips, d.clip.layerId, d.clip.id); + const origEnd = d.origStart + d.origDuration; + const maxStart = origEnd - MIN_DURATION; + const rawStart = Math.min(maxStart, Math.max(0, d.origStart + dxMin)); + const snapStart = nearestCandidate(rawStart, cands, tol); + let border = Math.min(maxStart, Math.max(0, snapStart !== null ? snapStart : snap(rawStart))); + // If a clip is flush against this border, roll it: move both edges. + const neighbor = others.find((o) => Math.abs(o.start + o.duration - d.origStart) < 1e-6); + if (neighbor) { + border = Math.max(neighbor.start + MIN_DURATION, Math.min(maxStart, border)); + setPreview([ + { id: neighbor.id, layerId: neighbor.layerId, start: neighbor.start, duration: border - neighbor.start }, + { id: d.clip.id, layerId: d.clip.layerId, start: border, duration: origEnd - border }, + ]); + } else { + const leftLimit = others.reduce((m, o) => { + const oe = o.start + o.duration; + return oe <= d.origStart ? Math.max(m, oe) : m; + }, 0); + border = Math.max(border, leftLimit); + setPreview([{ id: d.clip.id, layerId: d.clip.layerId, start: border, duration: origEnd - border }]); + } + } + }; + const detach = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + clipDragListenersRef.current = null; + unlockSelection(); + }; + const onUp = () => { + detach(); + const d = dragRef.current; + dragRef.current = null; + const p = previewRef.current; + setPreview(null); + if (!d) return; + // A tap with no movement drops the playhead on the clip and + // scrolls the screenplay to the matching spot. + if (!d.moved) { + setPlayhead(d.clip.start); + navigate(d.clip.start); + return; + } + if (!p || !repository) return; + for (const placement of p) { + const orig = timelineClips[placement.id]; + if (!orig) continue; + if ( + placement.layerId !== orig.layerId || + placement.start !== orig.start || + placement.duration !== orig.duration + ) { + repository.updateTimelineClip(placement.id, { + layerId: placement.layerId, + start: placement.start, + duration: placement.duration, + }); + } + } + }; + // A cancelled pointer stream (e.g. a trackpad gesture) aborts the drag + // without committing, so the clip never gets stuck to the cursor. + const onCancel = () => { + detach(); + dragRef.current = null; + setPreview(null); + }; + clipDragListenersRef.current = { move: onMove, up: onUp, cancel: onCancel }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + }, + [pxPerMin, layerAtY, setPreview, repository, navigate, timelineClips], + ); + + // Detach an in-progress clip drag if the panel unmounts. + useEffect(() => { + return () => { + const l = clipDragListenersRef.current; + if (l) { + window.removeEventListener("pointermove", l.move); + window.removeEventListener("pointerup", l.up); + window.removeEventListener("pointercancel", l.cancel); + unlockSelection(); + } + }; + }, []); + + // ---- Layer drag (pointer-based) ---- + + const layerDragIdRef = useRef(null); + const layerDropRef = useRef<{ id: string; pos: LayerDropPos } | null>(null); + const layerDragListenersRef = useRef<{ + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; + cancel: (e: PointerEvent) => void; + } | null>(null); + + // The hovered drop target (which lane, and before/into/after) drives the + // highlight declaratively — each row renders its own data-drop from this + // state. A pointer-based drag (not native HTML5 DnD) repaints on state + // change just fine. + const [dropTarget, setDropTarget] = useState<{ id: string; pos: LayerDropPos } | null>(null); + + // Whole label acts as the drag handle: press and move past a small threshold + // to reorder/nest a lane. Below the threshold a press is a plain click (or + // double-click to rename), so no visible grip is needed. Skipped while the + // lane's name is being edited so the caret can be placed in the input. + const onLayerPointerDown = useCallback( + (e: React.PointerEvent, layer: TimelineLayer) => { + if (e.button !== 0 || editingLayerId === layer.id) return; + const startX = e.clientX; + const startY = e.clientY; + let activated = false; + layerDragIdRef.current = layer.id; + layerDropRef.current = null; + + // Which lane (and where within it) is under the pointer. + const targetAtY = (clientY: number): { id: string; pos: LayerDropPos } | null => { + const el = tracksRef.current; + if (!el || visibleLayers.length === 0) return null; + const rect = el.getBoundingClientRect(); + let i = Math.floor((clientY - rect.top) / TRACK_H); + i = Math.max(0, Math.min(visibleLayers.length - 1, i)); + const target = visibleLayers[i].layer; + if (target.id === layerDragIdRef.current) return null; + const within = clientY - rect.top - i * TRACK_H; + const pos: LayerDropPos = + within < TRACK_H * 0.25 ? "before" : within > TRACK_H * 0.75 ? "after" : "into"; + return { id: target.id, pos }; + }; + + const activate = () => { + activated = true; + lockSelection(); + setDraggingLayerId(layer.id); + setDropTarget(null); + }; + const detach = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + layerDragListenersRef.current = null; + if (activated) { + setDropTarget(null); + unlockSelection(); + } + }; + const onMove = (ev: PointerEvent) => { + if (!activated) { + // Wait for a deliberate drag before hijacking the press. + if (Math.abs(ev.clientX - startX) <= 6 && Math.abs(ev.clientY - startY) <= 6) return; + activate(); + } + const target = targetAtY(ev.clientY); + layerDropRef.current = target; + setDropTarget(target); + }; + const onUp = () => { + detach(); + const dragId = layerDragIdRef.current; + const drop = layerDropRef.current; + layerDragIdRef.current = null; + layerDropRef.current = null; + if (!activated) return; // a plain click: nothing to commit + setDraggingLayerId(null); + if (dragId && drop) commitLayerMove(dragId, drop.id, drop.pos); + }; + const onCancel = () => { + detach(); + layerDragIdRef.current = null; + layerDropRef.current = null; + if (activated) setDraggingLayerId(null); + }; + layerDragListenersRef.current = { move: onMove, up: onUp, cancel: onCancel }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + }, + [visibleLayers, commitLayerMove, editingLayerId], + ); + + useEffect(() => { + return () => { + const l = layerDragListenersRef.current; + if (l) { + window.removeEventListener("pointermove", l.move); + window.removeEventListener("pointerup", l.up); + window.removeEventListener("pointercancel", l.cancel); + unlockSelection(); + } + }; + }, []); + + // ---- Layer + clip mutations ---- + + const addLayer = useCallback(() => { + repository?.addTimelineLayer(`${t("layer")} ${Object.keys(timelineLayers).length + 1}`); + }, [repository, timelineLayers, t]); + + const renameLayer = useCallback( + (id: string, name: string) => repository?.renameTimelineLayer(id, name), + [repository], + ); + + const deleteLayer = useCallback((id: string) => repository?.deleteTimelineLayer(id), [repository]); + + const removeClip = useCallback((id: string) => repository?.deleteTimelineClip(id), [repository]); + + // ---- Right-click menus (shared context-menu host) ---- + + const openLayerMenu = useCallback( + (e: React.MouseEvent, layer: TimelineLayer) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { + x: Math.min(e.clientX, window.innerWidth - 230), + y: Math.min(e.clientY, window.innerHeight - 160), + }, + content: ( + <> + setEditingLayerId(layer.id)} + /> + deleteLayer(layer.id)} /> + + ), + }); + }, + [updateContextMenu, t, deleteLayer], + ); + + const openClipMenu = useCallback( + (e: React.MouseEvent, clip: TimelineClip) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { + x: Math.min(e.clientX, window.innerWidth - 230), + y: Math.min(e.clientY, window.innerHeight - 120), + }, + content: ( + removeClip(clip.id)} /> + ), + }); + }, + [updateContextMenu, t, removeClip], + ); + + const isEmpty = Object.keys(timelineClips).length === 0; + + return ( +
+ {/* Toolbar */} +
+ {t("title")} +
+ + {/* Feature length */} +
+ + {lengthEditorOpen && ( +
+ +
+ { + const v = Math.max(1, Math.min(600, Math.round(Number(e.target.value) || 0))); + setFeatureLength(v); + repository?.setFeatureLength(v); + }} + /> + {t("unitMinutesShort")} +
+
+ )} +
+ + {/* Scale slider */} +
+ + setZoomIndex(Number(e.target.value))} + aria-label={t("scale")} + /> + +
+ + + +
+ + {/* Custom, always-present horizontal scrollbar (native overlay bars + auto-hide and take no space, so the timeline could not be scrolled + once zoomed past the container width). */} +
+
+
+
+
+ + {/* Layer tracks: scroll vertically (layers) and horizontally (time). */} +
+
+
+ {visibleLayers.map(({ layer, depth, hasChildren }) => { + return ( +
+
onLayerPointerDown(e, layer)} + onContextMenu={(e) => openLayerMenu(e, layer)} + > +
+ {hasChildren ? ( + + ) : ( + + )} + {editingLayerId === layer.id ? ( + renameLayer(layer.id, e.target.value)} + onBlur={() => setEditingLayerId(null)} + onKeyDown={(e) => { + // Enter/Escape commit by dropping focus (→ onBlur). + if (e.key === "Enter" || e.key === "Escape") e.currentTarget.blur(); + }} + /> + ) : ( + setEditingLayerId(layer.id)} + > + {layer.name || t("layerPlaceholder")} + + )} +
+
setPlayheadFromClientX(e.clientX)} + > + {Object.values(timelineClips) + .filter((c) => { + const p = preview?.find((x) => x.id === c.id) ?? c; + return p.layerId === layer.id; + }) + .map((clip) => { + const p = preview?.find((x) => x.id === clip.id) ?? clip; + const r = resolved[clip.id] ?? { + title: clip.title, + preview: clip.preview, + color: clip.color, + missing: true, + }; + const isCard = clip.source === "card"; + return ( +
onClipPointerDown(e, clip, "move")} + onContextMenu={(e) => openClipMenu(e, clip)} + > +
onClipPointerDown(e, clip, "resize-l")} + /> +
+ {!isCard && } + + {r.title || t("untitled")} + + {r.missing && ( + + )} + {p.duration * pxPerMin >= 52 && ( + + {formatDuration(p.duration)} + + )} +
+
onClipPointerDown(e, clip, "resize-r")} + /> +
+ ); + })} +
+
+ ); + })} + {isEmpty && visibleLayers.length > 0 && ( +
+ {t("empty")} +
+ )} + {playhead !== null && ( +
+ )} +
+
+
+ + {/* Scene overview line — a read-only band listing every scene in the + screenplay, proportional to its length. Sits just above the minute + ruler, following the tracks' horizontal scroll (via syncRuler) but + never scrolling vertically with the layers. */} +
+
+ {t("scenes")} +
+
+
+ {sceneSegments.map((seg) => ( +
+ {seg.title} +
+ ))} + {playhead !== null && ( +
+ )} +
+
+
+ + {/* Fixed minute ruler — always visible; follows the tracks' horizontal + scroll but never scrolls vertically with the layers. */} +
+
+ {t("unitMinutes")} +
+
setPlayheadFromClientX(e.clientX)} + > +
+ {eighthTicks.map((m) => ( +
+ ))} + {ticks.map((m) => ( +
+ {formatTick(m)} +
+ ))} + {playhead !== null && ( +
+ )} +
+
+
+
+ ); +}; + +export default TimelinePanel; diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index c9a25a41..66e6ba50 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -64,6 +64,19 @@ * already reserves this space, so the content box stays 56px tall. */ padding-block: 0; padding-top: var(--safe-top); + overflow: hidden; + transition: + height 0.25s ease, + opacity 0.25s ease; + } + + /* Collapsed while the reader scrolls down (see chromeHidden in ViewContext). + * Height animates to 0 so the editor below reclaims the space; the workspace + * is a flex column, so this reflows cleanly without a fixed overlay. */ + .container_hidden { + height: 0; + opacity: 0; + pointer-events: none; } } diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index dddde341..aed59a13 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -193,7 +193,7 @@ const ProjectNavbar = () => { const isLocalEdit = useRef(false); const isPhone = useIsPhone(); - const { setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden } = useViewContext(); // Return to the projects list by clearing the ?projectId query. Use the same // redirect() primitive that opens a project (ProjectItem → redirectScreenplay): @@ -286,7 +286,7 @@ const ProjectNavbar = () => { if (isPhone) { return ( -