-
-
- setLocalMoreLabel(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") commitMoreLabel();
- }}
- className={`${sharedStyles.input} ${styles.input}`}
- placeholder="MORE"
- />
-
+
+
+
+
+
+
+
+ setLocalContdLabel(e.target.value)}
+ className={`${sharedStyles.input} ${styles.input}`}
+ placeholder="CONT'D"
+ />
+
+
+
+
+
+ setLocalMoreLabel(e.target.value)}
+ className={`${sharedStyles.input} ${styles.input}`}
+ placeholder="MORE"
+ />
+
@@ -362,24 +583,24 @@ const LayoutSettings = () => {
);
};
diff --git a/components/dashboard/project/ProjectSettings.module.css b/components/dashboard/project/ProjectSettings.module.css
index 32ee387..42f7341 100644
--- a/components/dashboard/project/ProjectSettings.module.css
+++ b/components/dashboard/project/ProjectSettings.module.css
@@ -90,9 +90,14 @@
}
.formBtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ height: 40px;
background-color: var(--secondary);
border: 2px solid var(--tertiary);
- padding: 10px 36px;
+ padding: 0 36px;
border-radius: 40px;
font-weight: 600;
cursor: pointer;
diff --git a/components/dashboard/project/ProjectSettings.tsx b/components/dashboard/project/ProjectSettings.tsx
index ae43a6b..09e4e5a 100644
--- a/components/dashboard/project/ProjectSettings.tsx
+++ b/components/dashboard/project/ProjectSettings.tsx
@@ -9,7 +9,7 @@ import { useProjectMembership, useLocalProjectInfo, useProjectIdFromUrl } from "
import { ProjectContext } from "@src/context/ProjectContext";
import UploadButton from "@components/projects/UploadButton";
import DangerZone from "./DangerZone";
-import { ArrowRight } from "lucide-react";
+import { ArrowRight, Save } from "lucide-react";
import form from "./../../utils/Form.module.css";
import styles from "./ProjectSettings.module.css";
import dangerStyles from "./DangerZone.module.css";
@@ -162,6 +162,7 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
+
{t("saveChanges")}
diff --git a/components/editor/CommentCards.tsx b/components/editor/CommentCards.tsx
index 4aa12d8..eeed89d 100644
--- a/components/editor/CommentCards.tsx
+++ b/components/editor/CommentCards.tsx
@@ -1,12 +1,12 @@
"use client";
-import { useContext, useCallback, useEffect, useRef, useState, useMemo } from "react";
-import { ProjectContext } from "@src/context/ProjectContext";
+import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { Comment, CommentReply } from "@src/lib/utils/types";
import { Send, Trash2, X } from "lucide-react";
import { useUser } from "@src/lib/utils/hooks";
import { getCommentPositions } from "@src/lib/screenplay/extensions/comment-highlight-extension";
import { useViewContext } from "@src/context/ViewContext";
+import { Editor } from "@tiptap/react";
import styles from "./CommentCard.module.css";
function formatTimestamp(ts: number): string {
@@ -224,8 +224,25 @@ type ActiveLine = {
svgHeight: number;
};
-const CommentCards = () => {
- const { editor, comments, activeCommentId, setActiveCommentId, repository } = useContext(ProjectContext);
+export interface CommentCardsProps {
+ editor: Editor | null;
+ comments: Comment[];
+ activeCommentId: string | null;
+ setActiveCommentId: (id: string | null) => void;
+ onUpdateComment: (id: string, data: Partial) => void;
+ onDeleteComment: (id: string) => void;
+ onAddReply: (commentId: string, text: string, author: string) => void;
+}
+
+const CommentCards = ({
+ editor,
+ comments,
+ activeCommentId,
+ setActiveCommentId,
+ onUpdateComment,
+ onDeleteComment,
+ onAddReply,
+}: CommentCardsProps) => {
const { user } = useUser();
const { showComments } = useViewContext();
const cardRefs = useRef
diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx
new file mode 100644
index 0000000..d5fe019
--- /dev/null
+++ b/components/editor/DocumentEditorPanel.tsx
@@ -0,0 +1,420 @@
+"use client";
+
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import { isTauri } from "@tauri-apps/api/core";
+import { EditorContent } from "@tiptap/react";
+
+import { applyElement, insertElement, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor";
+import { ScreenplayElement } from "@src/lib/utils/enums";
+import { DEFAULT_ELEMENT_MARGINS, DEFAULT_ELEMENT_STYLES } from "@src/lib/project/project-state";
+import { join } from "@src/lib/utils/misc";
+import { useGlobalKeybinds, useProjectMembership, useSettings } from "@src/lib/utils/hooks";
+import { ProjectContext } from "@src/context/ProjectContext";
+import { useViewContext } from "@src/context/ViewContext";
+import { ContextMenuType } from "@components/editor/sidebar/ContextMenu";
+import { UserContext } from "@src/context/UserContext";
+import { useUser } from "@src/lib/utils/hooks";
+import CommentCards from "@components/editor/CommentCards";
+import Loading from "@components/utils/Loading";
+
+import { DocumentEditorConfig } from "@src/lib/editor/document-editor-config";
+import { useDocumentComments } from "@src/lib/editor/use-document-comments";
+import { useDocumentEditor } from "@src/lib/editor/use-document-editor";
+import type { SuggestionData } from "@components/editor/SuggestionMenu";
+
+import styles from "./EditorPanel.module.css";
+
+export interface DocumentEditorPanelProps {
+ config: DocumentEditorConfig;
+ isVisible: boolean;
+ /** Called when the Tiptap editor instance is created or destroyed. */
+ onEditorCreated?: (editor: import("@tiptap/react").Editor | null) => void;
+ // Screenplay-only props
+ suggestions?: string[];
+ updateSuggestions?: (suggestions: string[]) => void;
+ suggestionData?: SuggestionData;
+ updateSuggestionData?: (data: SuggestionData) => void;
+ userKeybinds?: Record
;
+ globalContext?: { toggleFocusMode: () => void; saveProject: () => void };
+}
+
+const DocumentEditorPanel = ({
+ config,
+ isVisible,
+ onEditorCreated,
+ suggestions = [],
+ updateSuggestions,
+ suggestionData,
+ updateSuggestionData,
+ userKeybinds,
+ globalContext,
+}: DocumentEditorPanelProps) => {
+ const { membership, isLoading } = useProjectMembership();
+ const { updateContextMenu } = useContext(UserContext);
+ const projectCtx = useContext(ProjectContext);
+ const {
+ isYjsReady,
+ selectedElement,
+ setSelectedElement,
+ setSelectedStyles,
+ pageFormat,
+ pageMargins,
+ displaySceneNumbers,
+ sceneHeadingSpacing,
+ sceneNumberOnRight,
+ contdLabel,
+ moreLabel,
+ elementMargins,
+ elementStyles,
+ setFocusedEditorType,
+ setSelectedTitlePageElement,
+ repository,
+ } = projectCtx;
+ const { settings } = useSettings();
+ const { isEndlessScroll, showComments } = useViewContext();
+ const { user } = useUser();
+
+ const [isEditorReady, setIsEditorReady] = useState(false);
+ const [isScrolled, setIsScrolled] = useState(false);
+
+ // Resolve the comments Y.Map for this document
+ const projectState = repository?.getState();
+ const commentsMap = useMemo(
+ () => (projectState && config.features.comments ? config.getCommentsMap(projectState) : null),
+ // Re-derive only when projectState identity changes (Yjs doc swap on project change)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [projectState],
+ );
+
+ // Per-document comment state
+ const commentOps = useDocumentComments(commentsMap, repository);
+
+ // Build the editor
+ const keybinds = userKeybinds ?? settings?.keybinds;
+
+ const updateActiveElement = useCallback(
+ (element: ScreenplayElement) => {
+ setSelectedElement(element);
+ },
+ [setSelectedElement],
+ );
+
+ const editor = useDocumentEditor(config, {
+ setActiveElement: updateActiveElement,
+ setSelectedStyles,
+ updateSuggestions,
+ updateSuggestionsData: updateSuggestionData,
+ setActiveCommentId: commentOps.setActiveCommentId,
+ userKeybinds: keybinds,
+ globalContext,
+ setSelectedTitlePageElement,
+ });
+
+ // Register the editor instance with the parent wrapper
+ useEffect(() => {
+ onEditorCreated?.(editor);
+ return () => { onEditorCreated?.(null); };
+ }, [editor, onEditorCreated]);
+
+ // Ready state
+ useEffect(() => {
+ if (editor && isYjsReady) {
+ const timer = setTimeout(() => setIsEditorReady(true), 500);
+ return () => clearTimeout(timer);
+ }
+ }, [editor, isYjsReady]);
+
+ // ---- CSS variable application (screenplay only) ----
+ useEffect(() => {
+ if (!editor || editor.isDestroyed || !editor.view?.dom) return;
+ if (config.type !== "screenplay") return;
+
+ const editorElement = editor.view.dom;
+
+ if (displaySceneNumbers) {
+ editorElement.classList.remove("hide-scene-numbers");
+ } else {
+ editorElement.classList.add("hide-scene-numbers");
+ }
+
+ editorElement.classList.remove("scene-heading-spacing-1.5", "scene-heading-spacing-2");
+ if (sceneHeadingSpacing === 1.5) {
+ editorElement.classList.add("scene-heading-spacing-1.5");
+ } else if (sceneHeadingSpacing === 2) {
+ editorElement.classList.add("scene-heading-spacing-2");
+ }
+
+ if (sceneNumberOnRight) {
+ editorElement.classList.add("scene-number-right");
+ } else {
+ editorElement.classList.remove("scene-number-right");
+ }
+
+ editorElement.style.setProperty("--contd-label", `"${contdLabel}"`);
+ editorElement.style.setProperty("--more-label", `"${moreLabel}"`);
+
+ const elementKeys = ["action", "scene", "character", "dialogue", "parenthetical", "transition", "section"] as const;
+ for (const key of elementKeys) {
+ const m = elementMargins[key] ?? DEFAULT_ELEMENT_MARGINS[key];
+ // Element CSS vars = page margin + element offset (total from page edge)
+ const totalLeft = pageMargins.left + (m?.left ?? 0);
+ const totalRight = pageMargins.right + (m?.right ?? 0);
+ editorElement.style.setProperty(`--${key}-l-margin`, `${totalLeft}in`);
+ editorElement.style.setProperty(`--${key}-r-margin`, `${totalRight}in`);
+ const s = { ...(DEFAULT_ELEMENT_STYLES[key] || {}), ...(elementStyles[key] || {}) };
+ editorElement.style.setProperty(`--${key}-align`, s.align ?? "left");
+ editorElement.style.setProperty(`--${key}-weight`, s.bold ? "bold" : "normal");
+ editorElement.style.setProperty(`--${key}-style`, s.italic ? "italic" : "normal");
+ editorElement.style.setProperty(`--${key}-decoration`, s.underline ? "underline" : "none");
+ editorElement.style.setProperty(`--${key}-transform`, s.uppercase ? "uppercase" : "none");
+ }
+
+ // Compute startNewPage types from element styles
+ const startNewPageTypes = new Set();
+ for (const key of elementKeys) {
+ const s = { ...(DEFAULT_ELEMENT_STYLES[key] || {}), ...(elementStyles[key] || {}) };
+ if (s.startNewPage) startNewPageTypes.add(key);
+ }
+
+ // Chain all pagination updates into a single transaction so options are
+ // set atomically before one recomputation (avoids intermediate states
+ // where some options are stale).
+ const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS];
+ if (pageSize) {
+ editor
+ .chain()
+ .updateStartNewPageTypes(startNewPageTypes)
+ .updatePageSize(pageSize)
+ .updateMargins({
+ top: pageMargins.top * 96,
+ bottom: pageMargins.bottom * 96,
+ left: pageMargins.left * 96,
+ right: pageMargins.right * 96,
+ })
+ .run();
+
+ }
+
+ if (isVisible) {
+ editor.commands.focus();
+ }
+ }, [editor, isVisible, config.type, pageFormat, pageMargins, displaySceneNumbers, sceneHeadingSpacing, sceneNumberOnRight, contdLabel, moreLabel, elementMargins, elementStyles]);
+
+ // ---- Pagination update (title page only) ----
+ useEffect(() => {
+ if (!editor || editor.isDestroyed || config.type !== "title") return;
+ const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS];
+ if (pageSize) {
+ editor
+ .chain()
+ .updatePageSize(pageSize)
+ .updateMargins({
+ top: pageMargins.top * 96,
+ bottom: pageMargins.bottom * 96,
+ left: pageMargins.left * 96,
+ right: pageMargins.right * 96,
+ })
+ .run();
+ }
+ }, [editor, config.type, pageFormat, pageMargins]);
+
+ // ---- handleKeyDown (screenplay only) ----
+ const selectedElementRef = useRef(selectedElement);
+ const updateContextMenuRef = useRef(updateContextMenu);
+ const updateSuggestionsRef = useRef(updateSuggestions);
+
+ useEffect(() => { selectedElementRef.current = selectedElement; }, [selectedElement]);
+ useEffect(() => { updateContextMenuRef.current = updateContextMenu; }, [updateContextMenu]);
+ useEffect(() => { updateSuggestionsRef.current = updateSuggestions; }, [updateSuggestions]);
+
+ const setActiveElement = useCallback(
+ (element: ScreenplayElement, applyStyle = true) => {
+ setSelectedElement(element);
+ if (applyStyle && editor) applyElement(editor, element);
+ },
+ [setSelectedElement, editor],
+ );
+
+ const setActiveElementRef = useRef(setActiveElement);
+ useEffect(() => { setActiveElementRef.current = setActiveElement; }, [setActiveElement]);
+
+ useEffect(() => {
+ if (!editor || config.type !== "screenplay") return;
+
+ editor.setOptions({
+ editorProps: {
+ handleKeyDown(view: any, event: any) {
+ const selection = view.state.selection;
+ const node = selection.$anchor.parent;
+ const nodeSize = node.content.size;
+ const nodePos = selection.$head.parentOffset;
+ const currNode = node.attrs.class as ScreenplayElement;
+
+ if (event.key === "Backspace") {
+ if (nodeSize === 1 && nodePos === 1) {
+ const tr = view.state.tr.delete(selection.from - 1, selection.from);
+ view.dispatch(tr);
+ return true;
+ }
+ return false;
+ }
+
+ if (event.code === "Space") {
+ if (currNode === ScreenplayElement.Action && node.textContent.match(/^\b(int|ext)\./gi)) {
+ setActiveElementRef.current(ScreenplayElement.Scene);
+ }
+ return false;
+ }
+
+ if (event.key === "Enter") {
+ const currentSuggestions = updateSuggestionsRef.current;
+ // suggestions.length check: read from ref to avoid stale closure
+ if (suggestions.length > 0) {
+ event.preventDefault();
+ return true;
+ }
+
+ if (nodePos < nodeSize) return false;
+
+ let newNode = ScreenplayElement.Action;
+ if (nodePos !== 0) {
+ switch (currNode) {
+ case ScreenplayElement.Character:
+ case ScreenplayElement.Parenthetical:
+ newNode = ScreenplayElement.Dialogue;
+ }
+ }
+ insertElement(editor, newNode, selection.$anchor.after());
+ return true;
+ }
+
+ return false;
+ },
+ },
+ });
+ }, [editor, config.type]);
+
+ // ---- Global keybinds (screenplay only) ----
+ const globalActions = useMemo(
+ () => globalContext ?? { toggleFocusMode: () => {}, saveProject: () => {} },
+ [globalContext],
+ );
+ useGlobalKeybinds(config.type === "screenplay" ? keybinds : undefined, globalActions);
+
+ // ---- Tab / Escape keyboard listener (screenplay only) ----
+ useEffect(() => {
+ if (!isVisible || config.type !== "screenplay") return;
+
+ const pressedKeyEvent = (e: KeyboardEvent) => {
+ if (e.key === "Tab") {
+ e.preventDefault();
+ switch (selectedElementRef.current) {
+ case ScreenplayElement.Action:
+ setActiveElementRef.current(ScreenplayElement.Character);
+ break;
+ case ScreenplayElement.Parenthetical:
+ setActiveElementRef.current(ScreenplayElement.Dialogue);
+ break;
+ case ScreenplayElement.Character:
+ setActiveElementRef.current(ScreenplayElement.Action);
+ break;
+ case ScreenplayElement.Dialogue:
+ setActiveElementRef.current(ScreenplayElement.Parenthetical);
+ break;
+ }
+ }
+
+ if (e.ctrlKey && e.key === "s") {
+ e.preventDefault();
+ }
+
+ if (e.key === "Escape") {
+ updateContextMenuRef.current(undefined);
+ updateSuggestionsRef.current?.([]);
+ }
+ };
+
+ addEventListener("keydown", pressedKeyEvent);
+ return () => removeEventListener("keydown", pressedKeyEvent);
+ }, [isVisible, config.type]);
+
+ // ---- Context menu ----
+ const onEditorContextMenu = useCallback(
+ (e: React.MouseEvent) => {
+ if (!editor) return;
+ const { from, to } = editor.state.selection;
+ if (from === to) return;
+
+ e.preventDefault();
+
+ const onAddComment = () => {
+ if (!editor) return;
+ const commentId = commentOps.addComment({
+ text: "",
+ author: user?.username || "Anonymous",
+ createdAt: Date.now(),
+ resolved: false,
+ replies: [],
+ });
+ editor.chain().setTextSelection({ from, to }).setComment(commentId).run();
+ commentOps.setActiveCommentId(commentId);
+ };
+
+ updateContextMenu({
+ type: ContextMenuType.EditorSelection,
+ position: { x: e.clientX, y: e.clientY },
+ typeSpecificProps: { from, to, onAddComment },
+ });
+ },
+ [editor, updateContextMenu, commentOps, user],
+ );
+
+ // Clear active comment on mousedown
+ const handleContainerMouseDown = useCallback(() => {
+ commentOps.setActiveCommentId(null);
+ }, [commentOps]);
+
+ const onScroll = (e: React.UIEvent) => {
+ if (suggestions.length > 0) updateSuggestions?.([]);
+ const scrollTop = e.currentTarget.scrollTop;
+ setIsScrolled(scrollTop > 0);
+ };
+
+ const focusType = config.type === "screenplay" ? "screenplay" : "title";
+
+ const isDesktop = isTauri();
+ if (!isDesktop && (!membership || isLoading)) return ;
+
+ return (
+
+
setFocusedEditorType(focusType)}
+ >
+
+ {config.features.comments && (
+
commentOps.updateComment(id, data)}
+ onDeleteComment={(id) => commentOps.deleteComment(id)}
+ onAddReply={(commentId, text, author) =>
+ commentOps.addReply(commentId, { text, author, createdAt: Date.now() })
+ }
+ />
+ )}
+
+
+ );
+};
+
+export default DocumentEditorPanel;
diff --git a/components/editor/EditorPanel.tsx b/components/editor/EditorPanel.tsx
index 544b518..d51ecb1 100644
--- a/components/editor/EditorPanel.tsx
+++ b/components/editor/EditorPanel.tsx
@@ -1,23 +1,12 @@
"use client";
-import { applyElement, insertElement, useScriptioEditor } from "@src/lib/screenplay/editor";
-import { SuggestionData } from "./SuggestionMenu";
-import { join } from "@src/lib/utils/misc";
-
-import { useContext, useEffect, useMemo, useState, useCallback, useRef } from "react";
-import { isTauri } from "@tauri-apps/api/core";
+import { useContext, useMemo } from "react";
import { UserContext } from "@src/context/UserContext";
-import { useViewContext } from "@src/context/ViewContext";
-import { DEFAULT_ELEMENT_STYLES } from "@src/lib/project/project-state";
-import { ScreenplayElement } from "@src/lib/utils/enums";
-
-import styles from "./EditorPanel.module.css";
-import { EditorContent } from "@node_modules/@tiptap/react/dist";
-import Loading from "@components/utils/Loading";
-import { useGlobalKeybinds, useProjectMembership, useSettings } from "@src/lib/utils/hooks";
import { ProjectContext } from "@src/context/ProjectContext";
-import CommentCards from "./CommentCards";
-import { ContextMenuType } from "./sidebar/ContextMenu";
+
+import { SCREENPLAY_EDITOR_CONFIG } from "@src/lib/editor/document-editor-config";
+import DocumentEditorPanel from "./DocumentEditorPanel";
+import type { SuggestionData } from "./SuggestionMenu";
interface EditorPanelProps {
isVisible: boolean;
@@ -28,294 +17,28 @@ interface EditorPanelProps {
}
const EditorPanel = ({ isVisible, suggestions, updateSuggestions, suggestionData, updateSuggestionData }: EditorPanelProps) => {
- const { membership, isLoading } = useProjectMembership();
- const { isZenMode, updateIsZenMode, updateContextMenu } = useContext(UserContext);
- const {
- isYjsReady,
- selectedElement,
- setSelectedElement,
- selectedStyles,
- setSelectedStyles,
- displaySceneNumbers,
- sceneHeadingSpacing,
- sceneNumberOnRight,
- contdLabel,
- moreLabel,
- elementMargins,
- elementStyles,
- setActiveCommentId,
- setFocusedEditorType,
- } = useContext(ProjectContext);
- const { settings } = useSettings();
-
- const [isEditorReady, setIsEditorReady] = useState(false);
- const [isScrolled, setIsScrolled] = useState(false);
+ const { updateIsZenMode } = useContext(UserContext);
+ const { updateEditor } = useContext(ProjectContext);
const globalActions = useMemo(
() => ({
- toggleFocusMode: () => updateIsZenMode((prev) => !prev),
+ toggleFocusMode: () => updateIsZenMode((prev: boolean) => !prev),
saveProject: () => console.log("Project Saved"),
}),
- [],
- );
-
- useGlobalKeybinds(settings?.keybinds, globalActions);
-
- const updateActiveElement = useCallback(
- (element: ScreenplayElement) => {
- setSelectedElement(element);
- },
- [setSelectedElement],
+ [updateIsZenMode],
);
- const editor = useScriptioEditor(
- membership?.project,
- updateActiveElement,
- setSelectedStyles,
- updateSuggestions,
- updateSuggestionData,
- settings?.keybinds,
- globalActions,
- );
-
- const setActiveElement = useCallback(
- (element: ScreenplayElement, applyStyle = true) => {
- setSelectedElement(element);
- if (applyStyle && editor) applyElement(editor, element);
- },
- [setSelectedElement, editor],
- );
-
- useEffect(() => {
- if (editor && isYjsReady) {
- const timer = setTimeout(() => setIsEditorReady(true), 500);
- return () => clearTimeout(timer);
- }
- }, [editor, isYjsReady]);
-
- useEffect(() => {
- if (!editor || editor.isDestroyed || !editor.view?.dom) return;
-
- const editorElement = editor.view.dom;
-
- // Scene numbers visibility
- if (displaySceneNumbers) {
- editorElement.classList.remove("hide-scene-numbers");
- } else {
- editorElement.classList.add("hide-scene-numbers");
- }
-
- // Scene heading spacing
- editorElement.classList.remove("scene-heading-spacing-1.5", "scene-heading-spacing-2");
- if (sceneHeadingSpacing === 1.5) {
- editorElement.classList.add("scene-heading-spacing-1.5");
- } else if (sceneHeadingSpacing === 2) {
- editorElement.classList.add("scene-heading-spacing-2");
- }
-
- // Scene number on right (class kept for potential future CSS use)
- if (sceneNumberOnRight) {
- editorElement.classList.add("scene-number-right");
- } else {
- editorElement.classList.remove("scene-number-right");
- }
-
- // CONT'D and MORE labels
- editorElement.style.setProperty("--contd-label", `"${contdLabel}"`);
- editorElement.style.setProperty("--more-label", `"${moreLabel}"`);
-
- // Element margins and styles
- const elementKeys = ["action", "scene", "character", "dialogue", "parenthetical", "transition", "section"] as const;
- for (const key of elementKeys) {
- // Margins
- const m = elementMargins[key];
- if (m) {
- editorElement.style.setProperty(`--${key}-l-margin`, `${m.left}in`);
- editorElement.style.setProperty(`--${key}-r-margin`, `${m.right}in`);
- } else {
- editorElement.style.removeProperty(`--${key}-l-margin`);
- editorElement.style.removeProperty(`--${key}-r-margin`);
- }
-
- // Styles
- const s = { ...(DEFAULT_ELEMENT_STYLES[key] || {}), ...(elementStyles[key] || {}) };
- editorElement.style.setProperty(`--${key}-align`, s.align ?? "left");
- editorElement.style.setProperty(`--${key}-weight`, s.bold ? "bold" : "normal");
- editorElement.style.setProperty(`--${key}-style`, s.italic ? "italic" : "normal");
- editorElement.style.setProperty(`--${key}-decoration`, s.underline ? "underline" : "none");
- }
-
- // Focus editor to trigger pagination recompute (only when visible to avoid stealing focus)
- if (isVisible) {
- editor.commands.focus();
- }
- }, [editor, isVisible, displaySceneNumbers, sceneHeadingSpacing, sceneNumberOnRight, contdLabel, moreLabel, elementMargins, elementStyles]);
-
- useEffect(() => {
- if (!editor) return;
-
- editor.setOptions({
- editorProps: {
- handleScrollToSelection: () => true,
- handleKeyDown(view: any, event: any) {
- const selection = view.state.selection;
- const node = selection.$anchor.parent;
- const nodeSize = node.content.size;
- const nodePos = selection.$head.parentOffset;
- const currNode = node.attrs.class as ScreenplayElement;
-
- if (event.key === "Backspace") {
- if (nodeSize === 1 && nodePos === 1) {
- const tr = view.state.tr.delete(selection.from - 1, selection.from);
- view.dispatch(tr);
- return true;
- }
- return false;
- }
-
- if (event.code === "Space") {
- if (currNode === ScreenplayElement.Action && node.textContent.match(/^\b(int|ext)\./gi)) {
- setActiveElement(ScreenplayElement.Scene);
- }
- return false;
- }
-
- if (event.key === "Enter") {
- if (suggestions.length > 0) {
- event.preventDefault();
- return true;
- }
-
- if (nodePos < nodeSize) {
- return false;
- }
-
- let newNode = ScreenplayElement.Action;
- if (nodePos !== 0) {
- switch (currNode) {
- case ScreenplayElement.Character:
- case ScreenplayElement.Parenthetical:
- newNode = ScreenplayElement.Dialogue;
- }
- }
-
- insertElement(editor, newNode, selection.$anchor.after());
- return true;
- }
-
- return false;
- },
- },
- });
- }, [editor]);
-
- const selectedElementRef = useRef(selectedElement);
- const setActiveElementRef = useRef(setActiveElement);
- const updateContextMenuRef = useRef(updateContextMenu);
- const updateSuggestionsRef = useRef(updateSuggestions);
-
- useEffect(() => {
- selectedElementRef.current = selectedElement;
- }, [selectedElement]);
-
- useEffect(() => {
- setActiveElementRef.current = setActiveElement;
- }, [setActiveElement]);
-
- useEffect(() => {
- updateContextMenuRef.current = updateContextMenu;
- }, [updateContextMenu]);
-
- useEffect(() => {
- updateSuggestionsRef.current = updateSuggestions;
- }, [updateSuggestions]);
-
- useEffect(() => {
- if (!isVisible) return;
-
- const pressedKeyEvent = (e: KeyboardEvent) => {
- if (e.key === "Tab") {
- e.preventDefault();
-
- switch (selectedElementRef.current) {
- case ScreenplayElement.Action:
- setActiveElementRef.current(ScreenplayElement.Character);
- break;
- case ScreenplayElement.Parenthetical:
- setActiveElementRef.current(ScreenplayElement.Dialogue);
- break;
- case ScreenplayElement.Character:
- setActiveElementRef.current(ScreenplayElement.Action);
- break;
- case ScreenplayElement.Dialogue:
- setActiveElementRef.current(ScreenplayElement.Parenthetical);
- }
- }
-
- if (e.ctrlKey && e.key === "s") {
- e.preventDefault();
- }
-
- if (e.key === "Escape") {
- updateContextMenuRef.current(undefined);
- updateSuggestionsRef.current([]);
- }
- };
-
- addEventListener("keydown", pressedKeyEvent);
- return () => {
- removeEventListener("keydown", pressedKeyEvent);
- };
- }, [isVisible]);
-
- const onScroll = (e: React.UIEvent) => {
- if (suggestions.length > 0) updateSuggestions([]);
- const scrollTop = e.currentTarget.scrollTop;
- setIsScrolled(scrollTop > 0);
- };
-
- const onEditorContextMenu = useCallback(
- (e: React.MouseEvent) => {
- if (!editor) return;
-
- const { from, to } = editor.state.selection;
- if (from === to) return;
-
- e.preventDefault();
- updateContextMenu({
- type: ContextMenuType.EditorSelection,
- position: { x: e.clientX, y: e.clientY },
- typeSpecificProps: { from, to },
- });
- },
- [editor, updateContextMenu],
- );
-
- // Clear active comment on mousedown anywhere in the container.
- // Uses mousedown so it fires *before* ProseMirror processes the click;
- // the editor's onSelectionUpdate will then override with the correct
- // comment ID if the cursor lands on a comment mark.
- const handleContainerMouseDown = useCallback(() => {
- setActiveCommentId(null);
- }, [setActiveCommentId]);
-
- const { isEndlessScroll, showComments } = useViewContext();
-
- const isDesktop = isTauri();
- if (!isDesktop && (!membership || isLoading)) return ;
-
return (
-
-
setFocusedEditorType("screenplay")}>
-
-
-
-
+
);
};
diff --git a/components/editor/TitlePagePanel.tsx b/components/editor/TitlePagePanel.tsx
index a14f0ad..da356f3 100644
--- a/components/editor/TitlePagePanel.tsx
+++ b/components/editor/TitlePagePanel.tsx
@@ -1,43 +1,98 @@
"use client";
-import { useContext, useEffect, useState } from "react";
-import { useTitlePageEditor } from "@src/lib/titlepage/editor";
+import { useCallback, useContext, useEffect, useRef, useState } from "react";
+import type { Editor } from "@tiptap/react";
+
import { ProjectContext } from "@src/context/ProjectContext";
-import { EditorContent } from "@tiptap/react";
-import Loading from "@components/utils/Loading";
-import { useProjectMembership } from "@src/lib/utils/hooks";
-import { isTauri } from "@tauri-apps/api/core";
+import { titlePageMetadataRef } from "@src/lib/titlepage/metadata-ref";
+import { DEFAULT_TITLEPAGE_CONTENT } from "@src/lib/titlepage/editor";
+import { TitlePageElement } from "@src/lib/utils/enums";
-import styles from "./TitlePagePanel.module.css";
+import { TITLEPAGE_EDITOR_CONFIG } from "@src/lib/editor/document-editor-config";
+import DocumentEditorPanel from "./DocumentEditorPanel";
const TitlePagePanel = ({ isVisible }: { isVisible?: boolean }) => {
- const { membership, isLoading } = useProjectMembership();
- const { isYjsReady, setFocusedEditorType } = useContext(ProjectContext);
- const [isEditorReady, setIsEditorReady] = useState(false);
+ const projectCtx = useContext(ProjectContext);
+ const { updateTitlePageEditor, isYjsReady, repository, projectTitle, projectAuthor } = projectCtx;
+
+ const [titleEditor, setTitleEditor] = useState(null);
+
+ // Keep the module-level ref in sync so format node views always get the latest values
+ titlePageMetadataRef.projectTitle = projectTitle || "";
+ titlePageMetadataRef.projectAuthor = projectAuthor || "";
+
+ // Synchronous storage update for nodes rendered before effects run
+ if (titleEditor && typeof titleEditor.storage === "object") {
+ const storage = (titleEditor.storage as any).titlePageMetadata;
+ if (storage) {
+ storage.projectTitle = projectTitle || "";
+ storage.projectAuthor = projectAuthor || "";
+ }
+ }
- const editor = useTitlePageEditor();
+ const handleEditorCreated = useCallback(
+ (editor: Editor | null) => {
+ updateTitlePageEditor(editor);
+ setTitleEditor(editor);
+ },
+ [updateTitlePageEditor],
+ );
+ // Initialize default template if title page is empty
useEffect(() => {
- if (editor && isYjsReady) {
- const timer = setTimeout(() => setIsEditorReady(true), 500);
- return () => clearTimeout(timer);
+ if (!titleEditor || !isYjsReady || !repository || !titleEditor.view) return;
+
+ const state = repository.getState();
+ const meta = state.metadata();
+
+ if (!meta.get("titlepageInitialized")) {
+ titleEditor.commands.setContent(DEFAULT_TITLEPAGE_CONTENT);
+
+ const { state: editorState, view } = titleEditor;
+ const tr = editorState.tr;
+ let modified = false;
+
+ tr.doc.descendants((node, pos) => {
+ if (node.type.name === TitlePageElement.Title) {
+ const markType = editorState.schema.marks.underline;
+ if (markType) {
+ tr.addMark(pos, pos + node.nodeSize, markType.create({ class: "underline" }));
+ modified = true;
+ }
+ return false;
+ }
+ });
+
+ if (modified && view) {
+ view.dispatch(tr);
+ }
+
+ meta.set("titlepageInitialized", true);
}
- }, [editor, isYjsReady]);
+ }, [titleEditor, isYjsReady, repository]);
- const isDesktop = isTauri();
- if (!isDesktop && (!membership || isLoading)) return ;
+ // Sync project metadata into editor storage for node view rendering
+ useEffect(() => {
+ if (!titleEditor || titleEditor.isDestroyed) return;
+ const storage = (titleEditor.storage as any).titlePageMetadata;
+ if (storage) {
+ storage.projectTitle = projectTitle || "";
+ storage.projectAuthor = projectAuthor || "";
+ storage.nodeViewUpdaters?.forEach((fn: () => void) => fn());
+ if (titleEditor.view && !titleEditor.view.isDestroyed) {
+ titleEditor.view.dispatch(titleEditor.state.tr.setMeta("titlePageMetadataUpdate", true));
+ }
+ }
+ }, [titleEditor, projectTitle, projectAuthor]);
return (
-
-
setFocusedEditorType("title")}>
-
-
-
-
-
+
);
};
export default TitlePagePanel;
+
diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx
index 35b313c..7f87ec8 100644
--- a/components/editor/sidebar/ContextMenu.tsx
+++ b/components/editor/sidebar/ContextMenu.tsx
@@ -10,7 +10,6 @@ import { LocationData, deleteLocation } from "@src/lib/screenplay/locations";
import { copyText, cutText, focusOnPosition, pasteText, selectTextInEditor } from "@src/lib/screenplay/editor";
import { addCharacterPopup, editCharacterPopup, editScenePopup } from "@src/lib/screenplay/popup";
import { ProjectContext } from "@src/context/ProjectContext";
-import { useUser } from "@src/lib/utils/hooks";
import { useTranslations } from "next-intl";
import {
ArrowDownRight,
@@ -179,30 +178,14 @@ const LocationItemMenu = (props: any) => {
export type EditorSelectionContextProps = {
from: number;
to: number;
+ onAddComment: () => void;
};
const EditorSelectionMenu = (props: any) => {
const t = useTranslations("contextMenu");
const projectCtx = useContext(ProjectContext);
- const { repository, editor, setActiveCommentId } = projectCtx;
- const { from, to } = props.props as EditorSelectionContextProps;
- const { user } = useUser();
-
- const handleAddComment = () => {
- if (!repository || !editor) return;
-
- const commentId = repository.addComment({
- text: "",
- author: user?.username || "Anonymous",
- createdAt: Date.now(),
- resolved: false,
- replies: [],
- });
-
- // Restore the original selection (lost when clicking the context menu) and apply the mark
- editor.chain().setTextSelection({ from, to }).setComment(commentId).run();
- setActiveCommentId(commentId);
- };
+ const { editor } = projectCtx;
+ const { from, to, onAddComment } = props.props as EditorSelectionContextProps;
const handleSearchOnWeb = () => {
if (!editor) return;
@@ -213,7 +196,7 @@ const EditorSelectionMenu = (props: any) => {
return (
<>
-
+
>
);
diff --git a/components/popup/PopupSceneItem.tsx b/components/popup/PopupSceneItem.tsx
index c2b3d62..401b81a 100644
--- a/components/popup/PopupSceneItem.tsx
+++ b/components/popup/PopupSceneItem.tsx
@@ -10,6 +10,7 @@ import { generateSceneId } from "@src/lib/screenplay/scenes";
import { ColorPicker } from "@components/utils/ColorPicker";
import { ScreenplayElement } from "@src/lib/utils/enums";
import { useTranslations } from "next-intl";
+import { Save } from "lucide-react";
import CloseSVG from "@public/images/close.svg";
@@ -121,6 +122,7 @@ export const PopupSceneItem = ({ data: { scene } }: PopupData) =
/>
diff --git a/components/projects/EmptyProjectPage.tsx b/components/projects/EmptyProjectPage.tsx
index 27d85b4..72e3e7d 100644
--- a/components/projects/EmptyProjectPage.tsx
+++ b/components/projects/EmptyProjectPage.tsx
@@ -1,9 +1,9 @@
"use client";
import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
import { useCookieUser } from "@src/lib/utils/hooks";
import { importFileAsProject, getSupportedImportExtensions } from "@src/lib/import/import-project";
-import { redirectScreenplay } from "@src/lib/utils/redirects";
import styles from "./EmptyProjectPage.module.css";
import { useTranslations } from "next-intl";
@@ -13,6 +13,7 @@ type Props = {
const EmptyProjectPage = ({ setIsCreating }: Props) => {
const { user } = useCookieUser();
+ const router = useRouter();
const fileInputRef = useRef