From 993c3b76f49b811093e6596076b90c0f29d39051 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 2 Sep 2026 02:31:02 +0200 Subject: [PATCH 1/2] added scene navigation filtering, ui tweaks --- .../EditorSidebarNavigation.module.css | 76 +++++ .../sidebar/EditorSidebarNavigation.tsx | 151 ++++++++- .../sidebar/SceneFilterPanel.module.css | 235 ++++++++++++++ .../editor/sidebar/SceneFilterPanel.tsx | 289 ++++++++++++++++++ .../editor/sidebar/SidebarItem.module.css | 10 + .../editor/sidebar/SidebarSceneItem.tsx | 6 +- components/navbar/ProjectNavbar.module.css | 134 ++++++++ components/navbar/ProjectNavbarDesktop.tsx | 151 ++++++--- components/navbar/ProjectNavbarSkeleton.tsx | 9 +- .../project/SplitPanelContainer.module.css | 6 + messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/ja.json | 8 + messages/ko.json | 8 + messages/pl.json | 8 + messages/zh.json | 8 + src/lib/screenplay/locations.ts | 24 +- src/lib/screenplay/scene-filters.ts | 243 +++++++++++++++ src/lib/screenplay/scenes.ts | 2 +- src/tests/project/scene-filters.test.ts | 186 +++++++++++ 22 files changed, 1535 insertions(+), 51 deletions(-) create mode 100644 components/editor/sidebar/SceneFilterPanel.module.css create mode 100644 components/editor/sidebar/SceneFilterPanel.tsx create mode 100644 src/lib/screenplay/scene-filters.ts create mode 100644 src/tests/project/scene-filters.test.ts diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index f138d3a5..9923e995 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -7,6 +7,9 @@ } .sidebar_content { + /* Containing block for the marker gutter, which is positioned into the right + padding below. */ + position: relative; display: flex; flex-direction: column; gap: 20px; @@ -139,6 +142,30 @@ flex: 1; } +/* One tick per scene the filter keeps, placed at its share of the list's height + — a density read-out of where the matches fall in the screenplay. + Sits in the sidebar's own right padding, outside the rounded panel (which + clips its overflow), so the scene titles keep the full panel width. `top` and + `height` are measured onto the scene list in EditorSidebarNavigation, since + nothing here lays the two out together. Mounted only while a filter is on, and + inert so it can't swallow a drag or a scroll aimed at the list. */ +.marker_gutter { + position: absolute; + right: 3px; + width: 6px; + pointer-events: none; +} + +.marker { + position: absolute; + left: 0; + right: 0; + height: 3px; + border-radius: 2px; + transform: translateY(-50%); + opacity: 0.8; +} + /* Document tree list. A hold anywhere on it opens the tree menu on touch (see DocumentTreeSidebarView), so iOS's native selection / callout must not claim the gesture first — it otherwise starts selecting the text behind the drawer @@ -196,6 +223,55 @@ } } +/* Scene filter trigger, pinned to the right end of the header — inset to line up + with the right edge of the scene items below it. Like .header_btn its vertical + padding is pulled back out with negative margins so the tap target stays + comfortable without making this header taller than the others. */ +.filter_btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin: -4px 20px -4px auto; + padding: 4px; + border: none; + border-radius: 6px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.15s; +} + +/* The icon keeps its secondary tint throughout; hover and the active filter + state read from the background and the badge instead. Set on the svg itself, + not inherited from the button: globals.css paints every `svg` with + --primary-text, which outranks anything the parent passes down. */ +.filter_btn svg { + color: var(--secondary-text); +} + +.filter_btn:hover, +.filter_btn_active { + background-color: var(--editor-sidebar-hover); +} + +/* Count of active filters, tucked into the button's top-right corner. */ +.filter_badge { + position: absolute; + top: -3px; + right: -3px; + min-width: 13px; + height: 13px; + padding: 0 3px; + border-radius: 7px; + background-color: var(--primary-text); + color: var(--editor-sidebar); + font-size: 9px; + font-weight: 700; + line-height: 13px; + text-align: center; +} + .list_title { font-size: 1rem; } diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index 42b44508..df2d9c86 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -1,7 +1,7 @@ "use client"; import { join } from "@src/lib/utils/misc"; -import { useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useContext, useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { useViewContext } from "@src/context/ViewContext"; @@ -9,8 +9,18 @@ import { Scene } from "@src/lib/screenplay/scenes"; import { focusOnPosition } from "@src/lib/screenplay/editor"; import { moveScene } from "@src/lib/screenplay/scene-reorder"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; -import { Archive, Clapperboard, FolderTree, MessageSquare } from "lucide-react"; +import { Archive, Clapperboard, FolderTree, ListFilter, MessageSquare } from "lucide-react"; +import { + EMPTY_SCENE_FILTER, + SceneFilter, + collectFacetOptions, + computeSceneFacets, + countSceneFilters, + isSceneFilterActive, + sceneMatchesFilter, +} from "@src/lib/screenplay/scene-filters"; import SidebarSceneItem from "./SidebarSceneItem"; +import SceneFilterPanel from "./SceneFilterPanel"; import ShelfSidebarView from "./ShelfSidebarView"; import CommentSidebarView from "./CommentSidebarView"; import DocumentTreeSidebarView from "./DocumentTreeSidebarView"; @@ -24,12 +34,18 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const TOUCH_DRAG_HOLD_MS = 300; const TOUCH_DRAG_CANCEL_PX = 10; +// useLayoutEffect warns on the server; fall back to useEffect there. Aligning +// the marker gutter has to happen before paint, or its ticks flash at the top +// of the sidebar before landing on the list. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); const { scenes, updateScenes, editor, + screenplay, sceneLocking, sceneNumberingStyle, skippedSceneLetters, @@ -39,6 +55,39 @@ const EditorSidebarNavigation = () => { const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments" | "documents">("scenes"); + // Scene filter (characters / locations / times of day), cumulative across + // the three dimensions. Kept here so the dimming survives the panel closing. + const [filter, setFilter] = useState(EMPTY_SCENE_FILTER); + const [filterOpen, setFilterOpen] = useState(false); + const filterBtnRef = useRef(null); + const filterActive = isSceneFilterActive(filter); + + // Facets are re-derived on every screenplay change, so only pay for them + // when something actually consumes them — the panel being open, or a filter + // dimming the list. + const facets = useMemo( + () => (filterOpen || filterActive ? computeSceneFacets(screenplay) : []), + [screenplay, filterOpen, filterActive], + ); + + // Keyed by scene heading position rather than by index: an optimistic drag + // reorder moves the scenes before the screenplay is re-parsed, and position + // keeps each scene matched to its own facets in the meantime. + const facetsByPosition = useMemo(() => new Map(facets.map((f) => [f.position, f])), [facets]); + const facetOptions = useMemo(() => collectFacetOptions(facets), [facets]); + + // Which scenes the filter excludes, in list order. Drives both the greyed + // out items and the marker gutter beside the list. + const filteredOut = useMemo( + () => + scenes.map( + (scene) => filterActive && !sceneMatchesFilter(facetsByPosition.get(scene.position), filter), + ), + [scenes, filterActive, facetsByPosition, filter], + ); + + const showMarkerGutter = activeTab === "scenes" && filterActive; + const [dragIndex, setDragIndex] = useState(null); // indicatorIndex represents the gap where the item will be inserted. // Gap i = "before item i". This way "bottom of item N" and "top of item N+1" @@ -69,6 +118,8 @@ const EditorSidebarNavigation = () => { }, [scenes, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes]); const listRef = useRef(null); + const sidebarContentRef = useRef(null); + const gutterRef = useRef(null); const currentSceneRef = useRef(null); const scenesRef = useRef(scenes); const suppressSceneScrollRef = useRef(false); @@ -155,6 +206,39 @@ const EditorSidebarNavigation = () => { list.scrollTo({ top: list.scrollTop + delta, behavior: "smooth" }); }, [currentSceneIndex, leftSidebarOpen]); + // The marker gutter is drawn beside the panel, not in it, so nothing lays it + // out against the scene list — it is measured onto it instead. Written + // straight to the node: a state round-trip would re-render the whole list on + // every resize. + useIsoLayoutEffect(() => { + const list = listRef.current; + const gutter = gutterRef.current; + const content = sidebarContentRef.current; + if (!list || !gutter || !content) return; + + const align = () => { + const listRect = list.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + gutter.style.top = `${listRect.top - contentRect.top}px`; + gutter.style.height = `${listRect.height}px`; + }; + + align(); + // Follows the list through sidebar open/close, window resizes and the + // timeline strip opening above the workspace. + const observer = new ResizeObserver(align); + observer.observe(list); + observer.observe(content); + return () => observer.disconnect(); + }, [showMarkerGutter]); + + // The filter popover belongs to the scenes tab: leaving it shuts the panel, + // while the filter itself is kept so coming back restores the same view. + const selectTab = useCallback((tab: "scenes" | "shelf" | "comments" | "documents") => { + setActiveTab(tab); + setFilterOpen(false); + }, []); + // End any in-progress drag and clear its drop indicator. const resetDrag = useCallback(() => { setDragIndex(null); @@ -309,14 +393,45 @@ const EditorSidebarNavigation = () => { return (
-
+
{activeTab === "scenes" ? ( <>

{t("scenes")}

+
+ {/* Portaled to , so it must not stay up over the + editor once the sidebar it hangs off is shut — a + collapsed column on desktop, a slid-out drawer on + phone. Reopening the sidebar brings it back. */} + {filterOpen && leftSidebarOpen && ( + setFilter(EMPTY_SCENE_FILTER)} + onClose={() => setFilterOpen(false)} + options={facetOptions} + /> + )}
{ index={index} label={display?.label ?? `${index + 1}`} isOmitted={display?.isOmitted ?? false} + isFilteredOut={filteredOut[index]} showDropIndicator={showIndicator} isDragging={dragIndex === index} isCurrent={isCurrent} @@ -364,30 +480,51 @@ const EditorSidebarNavigation = () => {
+ {/* Overview strip: one tick per scene the filter keeps, placed at its + share of the list's height, so it is obvious at a glance whether + the matches cluster or run through the whole screenplay. It sits + in the sidebar's own right padding, beside the panel rather than + inside it, so the scene titles keep the full panel width. */} + {showMarkerGutter && ( +
+ {scenes.map((scene: Scene, index: number) => + filteredOut[index] ? null : ( + + ), + )} +
+ )}
); diff --git a/components/editor/sidebar/SceneFilterPanel.module.css b/components/editor/sidebar/SceneFilterPanel.module.css new file mode 100644 index 00000000..061089fa --- /dev/null +++ b/components/editor/sidebar/SceneFilterPanel.module.css @@ -0,0 +1,235 @@ +/* Scene filter popover. Rendered in a body portal (the sidebar panel clips its + overflow) and positioned from the header button, hence `position: fixed` with + the offsets set inline. */ +.container { + composes: panel from "../../navbar/navbar-shared.module.css"; + position: fixed; + z-index: 100; + width: 280px; + max-height: min(460px, calc(100dvh - 120px)); + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} + +.title { + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-text); +} + +.header_actions { + display: flex; + align-items: center; + gap: 4px; +} + +.clear_btn { + padding: 3px 8px; + border: none; + border-radius: 6px; + background: none; + font-size: 0.72rem; + font-weight: 600; + color: var(--secondary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.clear_btn:hover:not(:disabled) { + background-color: var(--tertiary); + color: var(--primary-text); +} + +.clear_btn:disabled { + opacity: 0.4; + cursor: default; +} + +.close_btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + border-radius: 4px; +} + +.close_btn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + overflow-y: auto; + min-height: 0; +} + +.section { + display: flex; + flex-direction: column; +} + +/* Closed dropdown: dimension name on the left, current selection on the right. + The menu below is laid out in flow rather than floating, so a long list can + scroll inside the panel instead of being clipped by it. */ +.trigger { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--secondary); + font-size: 0.8rem; + text-align: left; + color: var(--primary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.trigger:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.trigger:disabled { + opacity: 0.5; + cursor: default; +} + +.trigger_active { + border-color: var(--primary-text); +} + +/* Set on the glyph, not the wrapper: globals.css paints every `svg` with + --primary-text, which outranks an inherited colour. */ +.trigger_icon svg { + display: block; + color: var(--secondary-text); +} + +.trigger_icon { + display: flex; + flex-shrink: 0; +} + +.trigger_label { + flex-shrink: 0; + white-space: nowrap; +} + +/* Fills the gap between the label and the chevron, so the selection sits hard + against the chevron however long the dimension name is. */ +.trigger_value { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: right; + font-size: 0.75rem; + color: var(--secondary-text); +} + +.chevron { + flex-shrink: 0; + color: var(--secondary-text); + transition: transform 0.2s ease; +} + +.chevron_open { + transform: rotate(180deg); +} + +.menu { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding: 4px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--primary); + max-height: 180px; + overflow-y: auto; +} + +.option { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: none; + font-size: 0.8rem; + text-align: left; + color: var(--secondary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +/* Typing inside an open menu walks to the matching row, which is shown as + though the pointer were on it. */ +.option:hover, +.option_highlighted { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +.option_selected { + color: var(--primary-text); +} + +.option_label { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.option_count { + flex-shrink: 0; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + color: var(--secondary-text); + opacity: 0.7; +} + +/* Always laid out, checked or not, so rows don't shift as they are picked. */ +.option_check { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; +} diff --git a/components/editor/sidebar/SceneFilterPanel.tsx b/components/editor/sidebar/SceneFilterPanel.tsx new file mode 100644 index 00000000..ec1cc661 --- /dev/null +++ b/components/editor/sidebar/SceneFilterPanel.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslations } from "next-intl"; +import { Check, ChevronDown, Clock, Compass, MapPin, Users, X } from "lucide-react"; +import { join } from "@src/lib/utils/misc"; +import { + FacetOption, + SceneFilter, + isSceneFilterActive, + toggleFilterValue, +} from "@src/lib/screenplay/scene-filters"; + +import styles from "./SceneFilterPanel.module.css"; + +// Layout effect on the client (flash-free positioning), plain effect on the +// server to avoid React's "useLayoutEffect does nothing on the server" warning. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +const PANEL_WIDTH = 280; +const VIEWPORT_MARGIN = 8; +/** How long typed letters keep accumulating before the next one starts a fresh + * search — the same idea as a native