diff --git a/components/analytics/AnalyticsModal.module.css b/components/analytics/AnalyticsModal.module.css
index 3925c354..25282df7 100644
--- a/components/analytics/AnalyticsModal.module.css
+++ b/components/analytics/AnalyticsModal.module.css
@@ -10,118 +10,106 @@
z-index: 1000;
}
-/* ── Modal shell ─────────────────────────────────────────── */
+/* ── Modal shell ─────────────────────────────────────────────
+ Same box as the dashboard modal (see DashboardModal.module.css): sized off the
+ viewport on both axes so the two modals keep the same footprint through a
+ rotation and read as one family. */
.modal {
- width: calc(100vw - 80px);
- max-width: 1200px;
- height: calc(100vh - 80px);
- max-height: 900px;
+ width: min(1100px, 92vw);
+ height: min(840px, 90vh);
display: flex;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
-/* ── Sidebar ─────────────────────────────────────────────── */
-.sidebar {
+/* ── Content area ────────────────────────────────────────────
+ The single pane: there is no nav sidebar, every section is stacked in the
+ scroll area below the header. */
+.content {
flex: 1;
- background: var(--editor-sidebar);
- padding: 32px 0;
- border-right: 1px solid var(--separator);
display: flex;
flex-direction: column;
+ padding: 30px;
+ background: var(--main-bg);
+ min-height: 0;
+ min-width: 0;
}
-.sidebarTitle {
- font-size: 1.2rem;
- padding: 0 24px;
- margin-bottom: 18px;
- color: var(--primary-text);
-}
-
-.groupLabel {
- font-family: var(--font-inter);
- font-size: 0.7rem;
- text-transform: uppercase;
- color: var(--secondary-text);
- margin-bottom: 5px;
- margin-top: 14px;
- padding: 0 12px;
- opacity: 0.6;
-}
-
-.navMenu {
- padding: 0 12px;
+.contentHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ flex-shrink: 0;
}
-.navItem {
+.title {
display: flex;
align-items: center;
- width: 100%;
- padding: 8px 12px;
- gap: 12px;
+ gap: 8px;
+ color: var(--primary-text);
+}
- background: transparent;
- border: none;
- border-radius: 8px;
+.title svg {
color: var(--secondary-text);
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s ease;
}
-.navItem.active {
- background: var(--editor-tab-active);
- color: var(--primary-text);
- font-weight: 600;
+.scrollArea {
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+ scrollbar-gutter: stable;
+ padding-right: 20px;
}
-.iconWrapper {
+/* Same size/hover as the dashboard's close button. */
+.close_btn {
display: flex;
align-items: center;
justify-content: center;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 10px;
+ background: none;
+ color: var(--secondary-text);
+ cursor: pointer;
}
-.iconWrapper.active svg {
+.close_btn:hover {
+ background-color: var(--secondary-hover);
color: var(--primary-text);
}
-.iconWrapper svg {
- color: var(--secondary-text);
+/* ── Sections ────────────────────────────────────────────────
+ All the analytics coexist in one scroll; the title is the only separator, with
+ a rule between consecutive sections to group each title with its own cards. */
+.section + .section {
+ margin-top: 32px;
+ padding-top: 28px;
+ border-top: 1px solid var(--separator);
}
-/* Content Area (2/3 Width) */
-.content {
- flex: 2;
+.sectionTitle {
display: flex;
- flex-direction: column;
- padding: 30px;
- background: var(--main-bg);
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 14px;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--primary-text);
}
-.contentHeader {
+.iconWrapper {
display: flex;
- justify-content: space-between;
align-items: center;
- margin-bottom: 20px;
-}
-
-.scrollArea {
- overflow-y: auto;
- flex: 1;
- scrollbar-gutter: stable;
- padding-right: 20px;
+ justify-content: center;
}
-.close_btn {
- height: 22px;
- width: 22px;
+.iconWrapper svg {
color: var(--secondary-text);
- cursor: pointer;
-}
-
-.close_btn:hover {
- color: var(--primary-text);
}
/* ── Stats page layout ───────────────────────────────────── */
@@ -190,13 +178,15 @@
padding: 40px 0;
}
-/* ── Reports placeholder ────────────────────────────────── */
+/* ── Reports placeholder ────────────────────────────────────
+ Padded rather than `height: 100%`: it is now one block in a stacked scroll,
+ not a pane filling the modal on its own. */
.comingSoon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- height: 100%;
+ padding: 40px 0;
gap: 12px;
color: var(--secondary-text);
opacity: 0.5;
@@ -214,13 +204,11 @@
/* ── Phone ────────────────────────────────────────────────────
A dedicated layout (rendered via a JS fork on phone viewports — see
AnalyticsModal) that matches the other navbar tool sheets: a full-width
- panel pinned below the navbar with a compact header, a section dropdown,
- and a scrollable body. These classes are only mounted on phone, so no
- media query is needed to gate them. */
+ panel pinned below the navbar with a compact header and a scrollable body.
+ These classes are only mounted on phone, so no media query is needed to gate
+ them. */
-/* Transparent full-screen catcher for outside taps. Its z-index sits below
- the portaled dropdown menu (.portal_menu, z-index 300) so the section
- picker's menu renders above the sheet rather than behind it. */
+/* Transparent full-screen catcher for outside taps. */
.mobileOverlay {
position: fixed;
inset: 0;
@@ -283,34 +271,6 @@
color: var(--primary-text);
}
-/* Section picker row. */
-.mobileToolbar {
- padding: 12px 16px;
- border-bottom: 1px solid var(--separator);
- flex-shrink: 0;
-}
-
-/* Constrains the dropdown so it doesn't stretch the full panel width. */
-.mobileTabField {
- width: 200px;
- max-width: 100%;
-}
-
-.tabSelectTrigger {
- padding: 8px 12px;
- background: var(--primary);
- border: 1px solid var(--separator);
- border-radius: 8px;
- color: var(--primary-text);
- font-size: 0.9rem;
-}
-
-.tabOption {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
/* Scrollable body — padded on the right so content clears the scrollbar. */
.mobileScroll {
flex: 1;
diff --git a/components/analytics/AnalyticsModal.tsx b/components/analytics/AnalyticsModal.tsx
index d7198b88..26c95f6b 100644
--- a/components/analytics/AnalyticsModal.tsx
+++ b/components/analytics/AnalyticsModal.tsx
@@ -1,11 +1,10 @@
"use client";
-import { useEffect, useState, ReactNode } from "react";
+import { useEffect, useState, ComponentType, ReactNode } from "react";
import { createPortal } from "react-dom";
import { BarChart2, Clapperboard, Users, MapPin, FileBarChart, X } from "lucide-react";
import { useIsPhone } from "@src/lib/utils/hooks";
-import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import ScenesStats from "./stats/ScenesStats";
import CharactersStats from "./stats/CharactersStats";
@@ -13,62 +12,6 @@ import LocationsStats from "./stats/LocationsStats";
import styles from "./AnalyticsModal.module.css";
-// ── Types ─────────────────────────────────────────────────────────────────────
-
-type AnalyticsTab = "scenes" | "characters" | "locations" | "reports";
-
-interface MenuItem {
- id: AnalyticsTab;
- label: string;
- icon: ReactNode;
-}
-
-interface MenuSection {
- group: string;
- items: MenuItem[];
-}
-
-// ── Sidebar data ──────────────────────────────────────────────────────────────
-
-const MENU: MenuSection[] = [
- {
- group: "Statistics",
- items: [
- { id: "scenes", label: "Scenes", icon: },
- { id: "characters", label: "Characters", icon: },
- { id: "locations", label: "Locations", icon: },
- ],
- },
- {
- group: "Reports",
- items: [
- { id: "reports", label: "Reports", icon: },
- ],
- },
-];
-
-// ── Tab titles ────────────────────────────────────────────────────────────────
-
-const TAB_TITLES: Record = {
- scenes: "Scenes",
- characters: "Characters",
- locations: "Locations",
- reports: "Reports",
-};
-
-// Flattened tabs for the mobile section dropdown (kept in sync with MENU).
-const TAB_OPTIONS: DropdownOption[] = MENU.flatMap((section) =>
- section.items.map((item) => ({
- value: item.id,
- label: (
-
- {item.icon}
- {item.label}
-
- ),
- })),
-);
-
// ── Reports placeholder ───────────────────────────────────────────────────────
function ReportsPlaceholder() {
@@ -80,6 +23,24 @@ function ReportsPlaceholder() {
);
}
+// ── Sections ──────────────────────────────────────────────────────────────────
+// Every section is rendered at once, stacked in the scroll area and separated by
+// its title — there is no tab state and no nav sidebar.
+
+interface Section {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ Content: ComponentType;
+}
+
+const SECTIONS: Section[] = [
+ { id: "scenes", label: "Scenes", icon: , Content: ScenesStats },
+ { id: "characters", label: "Characters", icon: , Content: CharactersStats },
+ { id: "locations", label: "Locations", icon: , Content: LocationsStats },
+ { id: "reports", label: "Reports", icon: , Content: ReportsPlaceholder },
+];
+
// ── Props ─────────────────────────────────────────────────────────────────────
interface AnalyticsModalProps {
@@ -90,7 +51,6 @@ interface AnalyticsModalProps {
// ── Component ─────────────────────────────────────────────────────────────────
export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) {
- const [activeTab, setActiveTab] = useState("scenes");
const [mounted, setMounted] = useState(false);
const isPhone = useIsPhone();
@@ -108,22 +68,21 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps)
if (!isOpen || !mounted) return null;
- const activeStats = (
- <>
- {activeTab === "scenes" && }
- {activeTab === "characters" && }
- {activeTab === "locations" && }
- {activeTab === "reports" && }
- >
- );
+ const sections = SECTIONS.map(({ id, label, icon, Content }) => (
+
+
+ {icon}
+ {label}
+
+
+
+ ));
// ── Mobile ──────────────────────────────────────────────────────────────
// A distinct layout that matches the other navbar tool sheets (Production,
// Read-aloud, Saves): a full-width panel pinned below the navbar with a
- // compact header, a section dropdown, and a scrollable body. Kept separate
- // from the desktop two-pane modal because the structures don't overlap.
- // The overlay carries a low z-index (below the portaled dropdown menu) so
- // the section picker's menu renders above the sheet, not behind it.
+ // compact header and a scrollable body. Kept separate from the desktop modal
+ // because the structures don't overlap.
if (isPhone) {
return createPortal(
,
document.body,
@@ -161,46 +107,23 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps)
// ── Desktop ─────────────────────────────────────────────────────────────
// Portal to so the overlay's fixed positioning escapes the navbar's
// transform/stacking context (which otherwise becomes its containing block).
+ // The shell is sized exactly like the dashboard modal (see its stylesheet).
return createPortal(
,
document.body,
diff --git a/components/editor/SceneCardsPanel.module.css b/components/editor/SceneCardsPanel.module.css
index cc98cedb..12f0e346 100644
--- a/components/editor/SceneCardsPanel.module.css
+++ b/components/editor/SceneCardsPanel.module.css
@@ -2,8 +2,9 @@
editor stays mounted behind (see PanelRenderer) so switching views never
reinitialises it or drops the ProjectContext editor handle. z-index 12 clears
the comment gutter, which floats at that level inside the editor beneath;
- equal specificity plus later DOM order puts this on top. Still below the
- panel switcher (13), which has to stay reachable to switch back. */
+ equal specificity plus later DOM order puts this on top. Still below the edge
+ handles (13) — the panel switcher and the right-sidebar toggle — which have to
+ stay reachable while the cards are up. */
.container {
position: absolute;
inset: 0;
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}
+ />
+ )}
+ {/* 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 && (
+
);
diff --git a/components/editor/sidebar/SceneFilterPanel.module.css b/components/editor/sidebar/SceneFilterPanel.module.css
new file mode 100644
index 00000000..a38b00c7
--- /dev/null
+++ b/components/editor/sidebar/SceneFilterPanel.module.css
@@ -0,0 +1,221 @@
+/* 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;
+}
+
+/* Both header actions are icon-only glyphs, so they share one look. */
+.clear_btn,
+.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;
+}
+
+.clear_btn:hover:not(:disabled),
+.close_btn:hover {
+ background-color: var(--tertiary);
+ color: var(--primary-text);
+}
+
+/* No filter set: nothing to clear. Dimming the whole button carries the icon
+ with it, whatever colour globals.css paints the glyph. */
+.clear_btn:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.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..04845d52
--- /dev/null
+++ b/components/editor/sidebar/SceneFilterPanel.tsx
@@ -0,0 +1,291 @@
+"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, FilterX, 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
-
+ {/* One island for the whole cluster: the chevron that folds the
+ tools away sits on the same pill they expand onto, so it
+ reads as a single control rather than a button parked next
+ to a pill. Folded, the island is just the chevron. */}
+
+ {/* Folded, this collapses to zero width and its group clips,
+ so the buttons inside are unreachable as well as invisible
+ — they stay mounted only so the reveal can animate. */}
)}
diff --git a/components/navbar/ProjectNavbarSkeleton.tsx b/components/navbar/ProjectNavbarSkeleton.tsx
index 9e41a529..6d92c4ec 100644
--- a/components/navbar/ProjectNavbarSkeleton.tsx
+++ b/components/navbar/ProjectNavbarSkeleton.tsx
@@ -58,7 +58,14 @@ const ProjectNavbarSkeleton = () => {
- {["saves", "production", "readaloud"].map(iconBtn)}
+ {/* The screenplay tools are folded behind a chevron on their own
+ island in [ProjectNavbarDesktop], so the skeleton reserves that
+ folded island rather than the three buttons it hides. */}
+
+
+
+
+
diff --git a/components/project/ProjectWorkspace.module.css b/components/project/ProjectWorkspace.module.css
index ae2617bf..dd1e01fb 100644
--- a/components/project/ProjectWorkspace.module.css
+++ b/components/project/ProjectWorkspace.module.css
@@ -32,7 +32,10 @@
/* Phone: near the edge (the native scrollbar is hidden there, so no overlap).
* Desktop offset is bumped below to clear the scrollbar gutter. */
right: 8px;
- z-index: 5;
+ /* Same level as the left edge handles (SplitPanelContainer's panel switcher):
+ above the scene-cards grid (12), which covers the panel opaquely — the
+ toggle has to stay reachable while the cards are up. */
+ z-index: 13;
width: 20px;
height: 36px;
display: flex;
@@ -44,6 +47,12 @@
border-radius: 16px;
}
+/* globals.css paints every `svg` with --primary-text, which outranks the colour
+ inherited from the button — so the chevron glyph is tinted directly. */
+.right_sidebar_toggle svg {
+ color: var(--secondary-text);
+}
+
/* Desktop/tablet: the editor scroll container reserves a scrollbar gutter
* (scrollbar-gutter: stable), so an 8px offset sits under the scrollbar. Pull the
* handle in to 30px so it clears the gutter and lines up with the same distance
diff --git a/components/project/SplitPanelContainer.module.css b/components/project/SplitPanelContainer.module.css
index deb484d5..c843c87f 100644
--- a/components/project/SplitPanelContainer.module.css
+++ b/components/project/SplitPanelContainer.module.css
@@ -181,6 +181,12 @@
border: none;
}
+/* globals.css paints every `svg` with --primary-text, which outranks the colour
+ inherited from the button — so the chevron/menu glyphs are tinted directly. */
+.panel_switcher_btn svg {
+ color: var(--secondary-text);
+}
+
@media (hover: hover) {
.panel_switcher_btn:hover {
opacity: 1;
diff --git a/messages/de.json b/messages/de.json
index 5afdd8b6..3814d3e5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -34,6 +34,7 @@
"redo": "Wiederholen",
"production": "Produktion",
"readAloud": "Vorlesen",
+ "tools": "Werkzeuge",
"analytics": "Analysen",
"settings": "Einstellungen",
"back": "Zurück zu Projekten",
@@ -353,6 +354,13 @@
"restore": "Wiederherstellen",
"cancel": "Abbrechen",
"scenesEmpty": "Noch keine Szenen",
+ "filterScenes": "Szenen filtern",
+ "filterClear": "Zurücksetzen",
+ "filterTimeOfDay": "Tageszeit",
+ "filterSceneType": "Szenentyp",
+ "filterAny": "Alle",
+ "filterSelected": "{count} ausgewählt",
+ "filterNoOptions": "Nichts zum Filtern",
"comments": "Kommentare",
"commentsEmpty": "Keine aktiven Kommentare",
"shelfEmptySelection": "Wählen Sie eine Version aus der Ablage zur Bearbeitung aus",
diff --git a/messages/en.json b/messages/en.json
index 3f7f24e5..984a16d9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -34,6 +34,7 @@
"redo": "Redo",
"production": "Production",
"readAloud": "Read Aloud",
+ "tools": "Tools",
"analytics": "Analytics",
"settings": "Settings",
"back": "Back to projects",
@@ -344,6 +345,13 @@
"editorSidebar": {
"scenes": "Scenes",
"scenesEmpty": "No scenes yet",
+ "filterScenes": "Filter scenes",
+ "filterClear": "Clear",
+ "filterTimeOfDay": "Time of day",
+ "filterSceneType": "Scene type",
+ "filterAny": "Any",
+ "filterSelected": "{count} selected",
+ "filterNoOptions": "Nothing to filter by",
"characters": "Characters",
"locations": "Locations",
"shelf": "Shelf",
diff --git a/messages/es.json b/messages/es.json
index 0344b141..afa7bf77 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -34,6 +34,7 @@
"redo": "Rehacer",
"production": "Producción",
"readAloud": "Leer en voz alta",
+ "tools": "Herramientas",
"analytics": "Analíticas",
"settings": "Ajustes",
"back": "Volver a proyectos",
@@ -352,6 +353,13 @@
"restore": "Restaurar",
"cancel": "Cancelar",
"scenesEmpty": "Aún no hay escenas",
+ "filterScenes": "Filtrar escenas",
+ "filterClear": "Limpiar",
+ "filterTimeOfDay": "Momento del día",
+ "filterSceneType": "Tipo de escena",
+ "filterAny": "Todos",
+ "filterSelected": "{count} seleccionados",
+ "filterNoOptions": "Nada por lo que filtrar",
"comments": "Comentarios",
"commentsEmpty": "No hay comentarios activos",
"shelfEmptySelection": "Selecciona una versión del estante para editar",
diff --git a/messages/fr.json b/messages/fr.json
index 34131384..90394d45 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -34,6 +34,7 @@
"redo": "Rétablir",
"production": "Production",
"readAloud": "Lecture à voix haute",
+ "tools": "Outils",
"analytics": "Analyses",
"settings": "Paramètres",
"back": "Retour aux projets",
@@ -353,6 +354,13 @@
"restore": "Restaurer",
"cancel": "Annuler",
"scenesEmpty": "Aucune scène pour le moment",
+ "filterScenes": "Filtrer les scènes",
+ "filterClear": "Effacer",
+ "filterTimeOfDay": "Moment de la journée",
+ "filterSceneType": "Type de scène",
+ "filterAny": "Tous",
+ "filterSelected": "{count} sélectionnés",
+ "filterNoOptions": "Rien à filtrer",
"comments": "Commentaires",
"commentsEmpty": "Aucun commentaire actif",
"shelfEmptySelection": "Sélectionnez une version sur l'étagère pour la modifier",
diff --git a/messages/ja.json b/messages/ja.json
index 13105f25..04e3e181 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -34,6 +34,7 @@
"redo": "やり直す",
"production": "制作",
"readAloud": "読み上げ",
+ "tools": "ツール",
"analytics": "分析",
"settings": "設定",
"back": "プロジェクトに戻る",
@@ -352,6 +353,13 @@
"restore": "復元",
"cancel": "キャンセル",
"scenesEmpty": "まだシーンはありません",
+ "filterScenes": "シーンを絞り込む",
+ "filterClear": "クリア",
+ "filterTimeOfDay": "時間帯",
+ "filterSceneType": "シーンタイプ",
+ "filterAny": "すべて",
+ "filterSelected": "{count}件選択",
+ "filterNoOptions": "絞り込む項目がありません",
"comments": "コメント",
"commentsEmpty": "アクティブなコメントはありません",
"shelfEmptySelection": "編集するシェルフのバージョンを選択してください",
diff --git a/messages/ko.json b/messages/ko.json
index fe959ccf..99c62a9b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -34,6 +34,7 @@
"redo": "다시 실행",
"production": "프로덕션",
"readAloud": "소리내어 읽기",
+ "tools": "도구",
"analytics": "분석",
"settings": "설정",
"back": "프로젝트로 돌아가기",
@@ -352,6 +353,13 @@
"restore": "복원",
"cancel": "취소",
"scenesEmpty": "아직 씬이 없습니다",
+ "filterScenes": "씬 필터",
+ "filterClear": "지우기",
+ "filterTimeOfDay": "시간대",
+ "filterSceneType": "씬 유형",
+ "filterAny": "전체",
+ "filterSelected": "{count}개 선택됨",
+ "filterNoOptions": "필터할 항목이 없습니다",
"comments": "코멘트",
"commentsEmpty": "활성 코멘트가 없습니다",
"shelfEmptySelection": "편집할 보관함 버전을 선택하세요",
diff --git a/messages/pl.json b/messages/pl.json
index 2abe334c..aba1c847 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -34,6 +34,7 @@
"redo": "Ponów",
"production": "Produkcja",
"readAloud": "Czytaj na głos",
+ "tools": "Narzędzia",
"analytics": "Analityka",
"settings": "Ustawienia",
"back": "Powrót do projektów",
@@ -352,6 +353,13 @@
"restore": "Przywróć",
"cancel": "Anuluj",
"scenesEmpty": "Brak scen",
+ "filterScenes": "Filtruj sceny",
+ "filterClear": "Wyczyść",
+ "filterTimeOfDay": "Pora dnia",
+ "filterSceneType": "Typ sceny",
+ "filterAny": "Wszystkie",
+ "filterSelected": "Wybrano: {count}",
+ "filterNoOptions": "Brak elementów do filtrowania",
"comments": "Komentarze",
"commentsEmpty": "Brak aktywnych komentarzy",
"shelfEmptySelection": "Wybierz wersję z półki do edycji",
diff --git a/messages/zh.json b/messages/zh.json
index a1152310..aa16e31e 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -34,6 +34,7 @@
"redo": "重做",
"production": "制作",
"readAloud": "朗读",
+ "tools": "工具",
"analytics": "分析",
"settings": "设置",
"back": "返回项目",
@@ -352,6 +353,13 @@
"restore": "恢复",
"cancel": "取消",
"scenesEmpty": "暂无场景",
+ "filterScenes": "筛选场景",
+ "filterClear": "清除",
+ "filterTimeOfDay": "时间",
+ "filterSceneType": "场景类型",
+ "filterAny": "全部",
+ "filterSelected": "已选 {count} 项",
+ "filterNoOptions": "没有可筛选的项目",
"comments": "评论",
"commentsEmpty": "没有活动的评论",
"shelfEmptySelection": "选择要编辑的搁置版本",
diff --git a/src/lib/screenplay/locations.ts b/src/lib/screenplay/locations.ts
index 23ab6f82..9e274318 100644
--- a/src/lib/screenplay/locations.ts
+++ b/src/lib/screenplay/locations.ts
@@ -121,21 +121,33 @@ export const getLocation = (name: string, projectCtx: ProjectContextType): Locat
// SCREENPLAY PARSING //
// -------------------------------- //
+/**
+ * Interior / exterior prefix of a scene heading, longest form first so the
+ * combined spellings win over the bare "INT"/"EXT" they start with. Exported
+ * because the location starts where this prefix ends.
+ */
+export const SCENE_TYPE_PATTERN = /^\s*(I\/E|E\/I|INT\.?\s*\/\s*EXT|EXT\.?\s*\/\s*INT|INT|EXT)\b\.?/i;
+
/**
* Extract location from a scene heading.
- * Takes everything between the first dot and the last hyphen.
+ * Takes everything between the INT./EXT. prefix and the last hyphen.
* Example: "INT. KITCHEN - DAY" -> "KITCHEN"
* Example: "EXT. JOHN'S HOUSE - BACKYARD - NIGHT" -> "JOHN'S HOUSE - BACKYARD"
+ * Example: "INT./EXT. CAR - NIGHT" -> "CAR"
*/
export const extractLocationFromSceneHeading = (sceneHeading: string): string | null => {
- const firstDotIndex = sceneHeading.indexOf(".");
const lastHyphenIndex = sceneHeading.lastIndexOf("-");
+ if (lastHyphenIndex === -1) return null;
- if (firstDotIndex === -1 || lastHyphenIndex === -1 || firstDotIndex >= lastHyphenIndex) {
- return null;
- }
+ // Skip the whole prefix rather than stopping at the first dot: a combined
+ // "INT./EXT." slugline would otherwise leave its "/EXT." in the location.
+ // Headings with no prefix at all keep the original first-dot behaviour.
+ const prefix = sceneHeading.match(SCENE_TYPE_PATTERN);
+ const start = prefix ? prefix[0].length : sceneHeading.indexOf(".") + 1;
+
+ if (start === 0 || start >= lastHyphenIndex) return null;
- const location = sceneHeading.substring(firstDotIndex + 1, lastHyphenIndex).trim();
+ const location = sceneHeading.substring(start, lastHyphenIndex).trim();
return location.length > 0 ? location.toUpperCase() : null;
};
diff --git a/src/lib/screenplay/scene-filters.ts b/src/lib/screenplay/scene-filters.ts
new file mode 100644
index 00000000..60fa9ded
--- /dev/null
+++ b/src/lib/screenplay/scene-filters.ts
@@ -0,0 +1,243 @@
+"use client";
+
+/**
+ * scene-filters.ts
+ *
+ * Per-scene facets (characters, location, time of day) used by the navigation
+ * sidebar's scene filter.
+ *
+ * Facets are computed from the parsed screenplay, never persisted: the location
+ * and the time of day come from the scene heading, the characters from the
+ * character cues inside the scene body. Each facet carries the scene heading's
+ * document `position`, which is what `Scene.position` holds — keying by it (not
+ * by index) keeps the association correct while an optimistic drag reorder is
+ * still waiting for the screenplay to be re-parsed.
+ */
+
+import { JSONContent } from "@tiptap/react";
+import { getNodeData } from "./screenplay";
+import { ScreenplayElement } from "../utils/enums";
+import { Screenplay } from "../utils/types";
+import { SCENE_TYPE_PATTERN, extractLocationFromSceneHeading } from "./locations";
+import { getJSONNodeSize } from "./scenes";
+
+// -------------------------------- //
+// TYPE DEFINITIONS //
+// -------------------------------- //
+
+export type SceneFacets = {
+ /** Document position of the scene heading — matches `Scene.position`. */
+ position: number;
+ /** Interior / exterior, normalised to "INT", "EXT" or "INT/EXT". */
+ sceneType: string | null;
+ location: string | null;
+ timeOfDay: string | null;
+ /** Unique, upper-cased character cues appearing in the scene. */
+ characters: string[];
+};
+
+/** The cumulative filter dimensions. Empty list = dimension unfiltered. */
+export type SceneFilter = {
+ characters: string[];
+ locations: string[];
+ timesOfDay: string[];
+ sceneTypes: string[];
+};
+
+/** A selectable value in the filter panel, with how many scenes carry it. */
+export type FacetOption = {
+ value: string;
+ count: number;
+};
+
+export const EMPTY_SCENE_FILTER: SceneFilter = {
+ characters: [],
+ locations: [],
+ timesOfDay: [],
+ sceneTypes: [],
+};
+
+/** Every dimension of a filter, in the order the panel lists them. */
+const FILTER_DIMENSIONS: (keyof SceneFilter)[] = ["characters", "locations", "timesOfDay", "sceneTypes"];
+
+export const countSceneFilters = (filter: SceneFilter): number =>
+ FILTER_DIMENSIONS.reduce((total, dimension) => total + filter[dimension].length, 0);
+
+export const isSceneFilterActive = (filter: SceneFilter): boolean => countSceneFilters(filter) > 0;
+
+// -------------------------------- //
+// SCENE PARSING //
+// -------------------------------- //
+
+/**
+ * Extract the time of day from a scene heading: everything after the last
+ * hyphen, which is where `extractLocationFromSceneHeading` stops.
+ * Example: "INT. KITCHEN - DAY" -> "DAY"
+ * Example: "EXT. JOHN'S HOUSE - BACKYARD - NIGHT" -> "NIGHT"
+ */
+export const extractTimeOfDayFromSceneHeading = (sceneHeading: string): string | null => {
+ const lastHyphenIndex = sceneHeading.lastIndexOf("-");
+ if (lastHyphenIndex === -1) return null;
+
+ const timeOfDay = sceneHeading.substring(lastHyphenIndex + 1).trim();
+ return timeOfDay.length > 0 ? timeOfDay.toUpperCase() : null;
+};
+
+/**
+ * Extract the scene type from a scene heading, normalised so the panel offers
+ * one option per type rather than one per spelling. Shares its pattern with the
+ * location extractor, which starts where this prefix ends.
+ * Example: "INT. KITCHEN - DAY" -> "INT"
+ * Example: "EXT./INT. CAR - NIGHT" -> "INT/EXT"
+ */
+export const extractSceneTypeFromSceneHeading = (sceneHeading: string): string | null => {
+ const match = sceneHeading.match(SCENE_TYPE_PATTERN);
+ if (!match) return null;
+
+ const type = match[1].replace(/[.\s]/g, "").toUpperCase();
+ if (type === "INT" || type === "EXT") return type;
+ return "INT/EXT";
+};
+
+/** Normalise a character cue the same way `getCharacterNames` does, so the
+ * facets match the names shown by the characters panel: upper-cased and
+ * stripped of extensions like "(V.O.)". */
+const cleanCharacterName = (rawName: string): string =>
+ rawName
+ .toUpperCase()
+ .trim()
+ .replace(/\s*\(.*?\)\s*$/, "")
+ .trim();
+
+const addCharacter = (facets: SceneFacets, rawName: string) => {
+ const name = cleanCharacterName(rawName);
+ if (name && !facets.characters.includes(name)) facets.characters.push(name);
+};
+
+/** Collect character cues nested inside a container node (dual dialogue). */
+const collectNestedCharacters = (node: JSONContent, facets: SceneFacets) => {
+ if (node.type === ScreenplayElement.Character) {
+ addCharacter(facets, getNodeData(node).flattenText);
+ return;
+ }
+
+ for (const child of node.content ?? []) collectNestedCharacters(child, facets);
+};
+
+/**
+ * Compute the filter facets of every scene, in document order.
+ *
+ * Mirrors `computeSceneItems`' cursor arithmetic so the reported positions line
+ * up with the scenes the sidebar renders.
+ */
+export const computeSceneFacets = (screenplay: Screenplay): SceneFacets[] => {
+ if (!screenplay) return [];
+
+ const facets: SceneFacets[] = [];
+ let current: SceneFacets | null = null;
+ let cursor = 1;
+
+ for (let i = 0; i < screenplay.length; i++) {
+ const node = getNodeData(screenplay[i]);
+
+ if (node.type === ScreenplayElement.None) {
+ cursor += 2; // empty screenplay element count for new line
+ continue;
+ }
+
+ // Container node: its cues live one level down, and its size has to be
+ // measured recursively to keep the cursor accurate.
+ if (screenplay[i].type === ScreenplayElement.DualDialogue) {
+ if (current) collectNestedCharacters(screenplay[i], current);
+ cursor += getJSONNodeSize(screenplay[i]);
+ continue;
+ }
+
+ if (node.type === ScreenplayElement.Scene) {
+ const heading = node.flattenText.toUpperCase();
+ current = {
+ position: cursor,
+ sceneType: extractSceneTypeFromSceneHeading(heading),
+ location: extractLocationFromSceneHeading(heading),
+ timeOfDay: extractTimeOfDayFromSceneHeading(heading),
+ characters: [],
+ };
+ facets.push(current);
+ } else if (current && node.type === ScreenplayElement.Character) {
+ addCharacter(current, node.flattenText);
+ }
+
+ cursor += node.flattenText.length + 2; // new line counts for 2 characters
+ }
+
+ return facets;
+};
+
+// -------------------------------- //
+// FILTERING //
+// -------------------------------- //
+
+/** A single-valued dimension passes when the scene's value is one of the
+ * selected ones; an unset selection lets every scene through. */
+const matchesSingleValue = (selected: string[], value: string | null): boolean =>
+ selected.length === 0 || (!!value && selected.includes(value));
+
+/**
+ * Whether a scene passes the filter. Dimensions are cumulative (AND), and so
+ * are the values within the character dimension: picking two characters keeps
+ * only the scenes where both of them speak. The other dimensions hold one value
+ * per scene, so their selections read as "any of" instead.
+ */
+export const sceneMatchesFilter = (facets: SceneFacets | undefined, filter: SceneFilter): boolean => {
+ if (!isSceneFilterActive(filter)) return true;
+ if (!facets) return false;
+
+ if (!filter.characters.every((name) => facets.characters.includes(name))) return false;
+ if (!matchesSingleValue(filter.locations, facets.location)) return false;
+ if (!matchesSingleValue(filter.timesOfDay, facets.timeOfDay)) return false;
+ if (!matchesSingleValue(filter.sceneTypes, facets.sceneType)) return false;
+
+ return true;
+};
+
+/** Toggle a value in one filter dimension, returning a new filter. */
+export const toggleFilterValue = (
+ filter: SceneFilter,
+ dimension: keyof SceneFilter,
+ value: string,
+): SceneFilter => {
+ const values = filter[dimension];
+ return {
+ ...filter,
+ [dimension]: values.includes(value) ? values.filter((v) => v !== value) : [...values, value],
+ };
+};
+
+/** Build the panel's option lists from the facets, sorted alphabetically. */
+export const collectFacetOptions = (facets: SceneFacets[]): Record => {
+ const characters = new Map();
+ const locations = new Map();
+ const timesOfDay = new Map();
+ const sceneTypes = new Map();
+
+ const bump = (map: Map, key: string) => map.set(key, (map.get(key) ?? 0) + 1);
+
+ for (const scene of facets) {
+ for (const name of scene.characters) bump(characters, name);
+ if (scene.location) bump(locations, scene.location);
+ if (scene.timeOfDay) bump(timesOfDay, scene.timeOfDay);
+ if (scene.sceneType) bump(sceneTypes, scene.sceneType);
+ }
+
+ const toOptions = (map: Map): FacetOption[] =>
+ [...map.entries()]
+ .map(([value, count]) => ({ value, count }))
+ .sort((a, b) => a.value.localeCompare(b.value));
+
+ return {
+ characters: toOptions(characters),
+ locations: toOptions(locations),
+ timesOfDay: toOptions(timesOfDay),
+ sceneTypes: toOptions(sceneTypes),
+ };
+};
diff --git a/src/lib/screenplay/scenes.ts b/src/lib/screenplay/scenes.ts
index e98a483a..44e6ec2d 100644
--- a/src/lib/screenplay/scenes.ts
+++ b/src/lib/screenplay/scenes.ts
@@ -30,7 +30,7 @@ import { compileSceneLabel } from "./scene-locking";
* For leaf nodes (text): text.length + 2 (opening + closing token).
* For branch nodes: 2 + sum of children sizes.
*/
-const getJSONNodeSize = (node: JSONContent): number => {
+export const getJSONNodeSize = (node: JSONContent): number => {
if (node.text !== undefined) return node.text?.length ?? 0;
const childrenSize = (node.content ?? []).reduce((acc, child) => acc + getJSONNodeSize(child), 0);
return 2 + childrenSize;
diff --git a/src/tests/project/scene-filters.test.ts b/src/tests/project/scene-filters.test.ts
new file mode 100644
index 00000000..6576f709
--- /dev/null
+++ b/src/tests/project/scene-filters.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from "vitest";
+import { JSONContent } from "@tiptap/react";
+
+import { ScreenplayElement } from "@src/lib/utils/enums";
+import { DUAL_DIALOGUE_COLUMN } from "@src/lib/screenplay/nodes";
+import { extractLocationFromSceneHeading } from "@src/lib/screenplay/locations";
+import { computeSceneItems } from "@src/lib/screenplay/scenes";
+import {
+ collectFacetOptions,
+ computeSceneFacets,
+ extractSceneTypeFromSceneHeading,
+ extractTimeOfDayFromSceneHeading,
+ sceneMatchesFilter,
+ toggleFilterValue,
+ EMPTY_SCENE_FILTER,
+} from "@src/lib/screenplay/scene-filters";
+
+const node = (type: string, text: string): JSONContent => ({
+ type,
+ content: [{ type: "text", text }],
+});
+
+const scene = (heading: string) => node(ScreenplayElement.Scene, heading);
+const character = (name: string) => node(ScreenplayElement.Character, name);
+const action = (text: string) => node(ScreenplayElement.Action, text);
+const dialogue = (text: string) => node(ScreenplayElement.Dialogue, text);
+
+const dualDialogue = (left: string, right: string): JSONContent => ({
+ type: ScreenplayElement.DualDialogue,
+ content: [
+ { type: DUAL_DIALOGUE_COLUMN, content: [character(left), dialogue("Left line.")] },
+ { type: DUAL_DIALOGUE_COLUMN, content: [character(right), dialogue("Right line.")] },
+ ],
+});
+
+const screenplay: JSONContent[] = [
+ scene("INT. KITCHEN - DAY"),
+ action("Steam on the windows."),
+ character("ANNA"),
+ dialogue("Coffee?"),
+ character("BEN (V.O.)"),
+ dialogue("Always."),
+ scene("EXT. JOHN'S HOUSE - BACKYARD - NIGHT"),
+ character("ANNA"),
+ dialogue("It's late."),
+ scene("INT. KITCHEN - NIGHT"),
+ dualDialogue("CARL", "ANNA"),
+];
+
+describe("scene facets", () => {
+ it("reports the type, location, time of day and cues of every scene", () => {
+ const facets = computeSceneFacets(screenplay);
+
+ expect(facets.map((f) => f.sceneType)).toEqual(["INT", "EXT", "INT"]);
+ expect(facets.map((f) => f.location)).toEqual(["KITCHEN", "JOHN'S HOUSE - BACKYARD", "KITCHEN"]);
+ expect(facets.map((f) => f.timeOfDay)).toEqual(["DAY", "NIGHT", "NIGHT"]);
+ // Cue extensions are stripped, and dual-dialogue cues are found one
+ // level down inside the container node.
+ expect(facets.map((f) => f.characters)).toEqual([["ANNA", "BEN"], ["ANNA"], ["CARL", "ANNA"]]);
+ });
+
+ it("reports positions that line up with the parsed scenes", () => {
+ const facets = computeSceneFacets(screenplay);
+ const scenes = computeSceneItems(screenplay);
+
+ expect(facets.map((f) => f.position)).toEqual(scenes.map((s) => s.position));
+ });
+
+ it("reads the time of day as the text after the last hyphen", () => {
+ expect(extractTimeOfDayFromSceneHeading("INT. KITCHEN - DAY")).toBe("DAY");
+ expect(extractTimeOfDayFromSceneHeading("EXT. HOUSE - BACKYARD - CONTINUOUS")).toBe("CONTINUOUS");
+ expect(extractTimeOfDayFromSceneHeading("INT. KITCHEN")).toBeNull();
+ });
+
+ it("normalises the interior / exterior prefix", () => {
+ expect(extractSceneTypeFromSceneHeading("INT. KITCHEN - DAY")).toBe("INT");
+ expect(extractSceneTypeFromSceneHeading("EXT. STREET - DAY")).toBe("EXT");
+ expect(extractSceneTypeFromSceneHeading("INT./EXT. CAR - NIGHT")).toBe("INT/EXT");
+ expect(extractSceneTypeFromSceneHeading("EXT./INT. CAR - NIGHT")).toBe("INT/EXT");
+ expect(extractSceneTypeFromSceneHeading("I/E. CAR - NIGHT")).toBe("INT/EXT");
+ // Not a slugline prefix: "INTERIOR" must not be read as "INT".
+ expect(extractSceneTypeFromSceneHeading("INTERIOR DECORATOR'S OFFICE - DAY")).toBeNull();
+ expect(extractSceneTypeFromSceneHeading("THE NEXT MORNING")).toBeNull();
+ });
+
+ it("keeps the prefix out of the location, combined sluglines included", () => {
+ expect(extractLocationFromSceneHeading("INT. KITCHEN - DAY")).toBe("KITCHEN");
+ expect(extractLocationFromSceneHeading("EXT. HOUSE - BACKYARD - NIGHT")).toBe("HOUSE - BACKYARD");
+ // The second half of a combined prefix used to be read as the location.
+ expect(extractLocationFromSceneHeading("INT./EXT. CAR - NIGHT")).toBe("CAR");
+ expect(extractLocationFromSceneHeading("EXT./INT. CAR - NIGHT")).toBe("CAR");
+ expect(extractLocationFromSceneHeading("I/E. CAR - NIGHT")).toBe("CAR");
+ // No prefix: unchanged: everything between the first dot and the last hyphen.
+ expect(extractLocationFromSceneHeading("FLASHBACK. THE PIER - DAWN")).toBe("THE PIER");
+ expect(extractLocationFromSceneHeading("THE NEXT MORNING")).toBeNull();
+ });
+
+ it("counts each facet value across the scenes", () => {
+ const options = collectFacetOptions(computeSceneFacets(screenplay));
+
+ expect(options.characters).toEqual([
+ { value: "ANNA", count: 3 },
+ { value: "BEN", count: 1 },
+ { value: "CARL", count: 1 },
+ ]);
+ expect(options.locations).toEqual([
+ { value: "JOHN'S HOUSE - BACKYARD", count: 1 },
+ { value: "KITCHEN", count: 2 },
+ ]);
+ expect(options.timesOfDay).toEqual([
+ { value: "DAY", count: 1 },
+ { value: "NIGHT", count: 2 },
+ ]);
+ expect(options.sceneTypes).toEqual([
+ { value: "EXT", count: 1 },
+ { value: "INT", count: 2 },
+ ]);
+ });
+});
+
+describe("scene filtering", () => {
+ const facets = computeSceneFacets(screenplay);
+ const matches = (filter: Parameters[1]) =>
+ facets.filter((f) => sceneMatchesFilter(f, filter)).map((f) => facets.indexOf(f));
+
+ it("keeps every scene while no filter is set", () => {
+ expect(matches(EMPTY_SCENE_FILTER)).toEqual([0, 1, 2]);
+ });
+
+ it("requires every selected character to be in the scene", () => {
+ expect(matches({ ...EMPTY_SCENE_FILTER, characters: ["ANNA"] })).toEqual([0, 1, 2]);
+ expect(matches({ ...EMPTY_SCENE_FILTER, characters: ["ANNA", "BEN"] })).toEqual([0]);
+ expect(matches({ ...EMPTY_SCENE_FILTER, characters: ["BEN", "CARL"] })).toEqual([]);
+ });
+
+ it("accepts any of the selected locations, times of day and scene types", () => {
+ expect(matches({ ...EMPTY_SCENE_FILTER, locations: ["KITCHEN"] })).toEqual([0, 2]);
+ expect(matches({ ...EMPTY_SCENE_FILTER, timesOfDay: ["DAY", "NIGHT"] })).toEqual([0, 1, 2]);
+ expect(matches({ ...EMPTY_SCENE_FILTER, sceneTypes: ["EXT"] })).toEqual([1]);
+ expect(matches({ ...EMPTY_SCENE_FILTER, sceneTypes: ["INT", "EXT"] })).toEqual([0, 1, 2]);
+ });
+
+ it("combines every dimension cumulatively", () => {
+ expect(
+ matches({
+ characters: ["ANNA", "CARL"],
+ locations: ["KITCHEN"],
+ timesOfDay: ["NIGHT"],
+ sceneTypes: ["INT"],
+ }),
+ ).toEqual([2]);
+ // Same scene, wrong time of day.
+ expect(
+ matches({
+ characters: ["ANNA", "CARL"],
+ locations: ["KITCHEN"],
+ timesOfDay: ["DAY"],
+ sceneTypes: ["INT"],
+ }),
+ ).toEqual([]);
+ // Same scene, wrong scene type.
+ expect(
+ matches({
+ characters: ["ANNA", "CARL"],
+ locations: ["KITCHEN"],
+ timesOfDay: ["NIGHT"],
+ sceneTypes: ["EXT"],
+ }),
+ ).toEqual([]);
+ });
+
+ it("excludes a scene with no facets at all once a filter is on", () => {
+ expect(sceneMatchesFilter(undefined, { ...EMPTY_SCENE_FILTER, locations: ["KITCHEN"] })).toBe(false);
+ expect(sceneMatchesFilter(undefined, EMPTY_SCENE_FILTER)).toBe(true);
+ });
+
+ it("toggles a value in one dimension without touching the others", () => {
+ const withAnna = toggleFilterValue(EMPTY_SCENE_FILTER, "characters", "ANNA");
+ expect(withAnna).toEqual({ characters: ["ANNA"], locations: [], timesOfDay: [], sceneTypes: [] });
+
+ const withBoth = toggleFilterValue(withAnna, "characters", "BEN");
+ expect(withBoth.characters).toEqual(["ANNA", "BEN"]);
+
+ expect(toggleFilterValue(withBoth, "characters", "ANNA").characters).toEqual(["BEN"]);
+ });
+});