diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index f707bef..6abac56 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -2,8 +2,8 @@ import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react"; import { ProjectContext } from "@src/context/ProjectContext"; -import { getBoardMap } from "@src/lib/project/project-state"; -import BoardCard, { BoardCardData } from "./BoardCard"; +import { getBoardMap, BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; +import BoardCard from "./BoardCard"; import styles from "./BoardCanvas.module.css"; import { v4 as uuidv4 } from "uuid"; import { Trash2, Plus, Minus, Copy } from "lucide-react"; @@ -25,12 +25,6 @@ const DEFAULT_CARD_COLORS = [ "#6b7280", ]; -export interface BoardArrowData { - id: string; - fromCardId: string; - toCardId: string; -} - interface CardContextMenuState { position: { x: number; y: number }; card: BoardCardData; @@ -460,6 +454,7 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { // Create new card on double-click const handleDoubleClick = useCallback( (e: React.MouseEvent) => { + e.preventDefault(); if ((e.target as HTMLElement).closest(`.${styles.card}`)) return; if ((e.target as HTMLElement).closest(`.${styles.zoom_controls}`)) return; if ((e.target as HTMLElement).closest(`.${styles.hints}`)) return; diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index ee25f0e..e571dd7 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -3,17 +3,7 @@ import { useRef, useState, useCallback, useEffect } from "react"; import styles from "./BoardCanvas.module.css"; import { useTranslations } from "next-intl"; - -export interface BoardCardData { - id: string; - title: string; - description: string; - color: string; - x: number; - y: number; - width: number; - height: number; -} +import { BoardCardData } from "@src/lib/project/project-state"; interface BoardCardProps { card: BoardCardData; @@ -129,6 +119,7 @@ const BoardCard = ({ const handleResizeStart = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); + e.preventDefault(); resizeStart.current = { x: e.clientX, y: e.clientY, diff --git a/components/dashboard/account/ProfileSettings.tsx b/components/dashboard/account/ProfileSettings.tsx index 3f5b273..fdf457c 100644 --- a/components/dashboard/account/ProfileSettings.tsx +++ b/components/dashboard/account/ProfileSettings.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { editUserInfo, logout } from "@src/lib/utils/requests"; import { useRouter } from "next/navigation"; -import { ArrowRight, Trash2 } from "lucide-react"; +import { ArrowRight, Trash2, Save } from "lucide-react"; import { useTranslations } from "next-intl"; import form from "./../../utils/Form.module.css"; @@ -226,6 +226,7 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
diff --git a/components/dashboard/preferences/KeybindsSettings.tsx b/components/dashboard/preferences/KeybindsSettings.tsx index 4b1b09b..748a8db 100644 --- a/components/dashboard/preferences/KeybindsSettings.tsx +++ b/components/dashboard/preferences/KeybindsSettings.tsx @@ -11,6 +11,7 @@ import { tinykeys } from "@node_modules/tinykeys/dist/tinykeys"; import { editUserSettings } from "@src/lib/utils/requests"; import { DEFAULT_KEYBINDS, DefaultKeyBind, prettyPrintKeybind, UserKeybindsMap } from "@src/lib/utils/keybinds"; import { useTranslations } from "next-intl"; +import { Save } from "lucide-react"; export type KeybindElementProps = { id: string; @@ -261,6 +262,7 @@ const KeybindsSettings = () => { disabled={!hasUpdatedKeybinds} className={`${sharedStyles.formBtn}`} > + {t("save")} diff --git a/components/dashboard/project/DangerZone.module.css b/components/dashboard/project/DangerZone.module.css index cd54d26..dcde0a4 100644 --- a/components/dashboard/project/DangerZone.module.css +++ b/components/dashboard/project/DangerZone.module.css @@ -1,7 +1,7 @@ .arrowBtn { display: flex; - width: 36px; - height: 36px; + width: 40px; + height: 40px; flex-shrink: 0; border-radius: 50%; border: 2px solid var(--separator); diff --git a/components/dashboard/project/ExportProject.tsx b/components/dashboard/project/ExportProject.tsx index 2614a96..e450ef4 100644 --- a/components/dashboard/project/ExportProject.tsx +++ b/components/dashboard/project/ExportProject.tsx @@ -12,11 +12,12 @@ import styles from "./ExportProject.module.css"; import optionCard from "./OptionCard.module.css"; import { importFilePopup } from "@src/lib/screenplay/popup"; import { UserContext } from "@src/context/UserContext"; -import { getAdapterByExtension, getAdapterByFilename } from "@src/lib/adapters/registry"; +import { getAdapterByExtension } from "@src/lib/adapters/registry"; import { BaseExportOptions } from "@src/lib/adapters/screenplay-adapter"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; import { PDFExportOptions } from "@src/lib/adapters/pdf/pdf-adapter"; import { ScriptioExportOptions } from "@src/lib/adapters/scriptio/scriptio-adapter"; +import { importFileIntoProject } from "@src/lib/import/import-project"; export enum ExportFormat { PDF = "pdf", @@ -68,26 +69,15 @@ const ExportProject = () => { const file = event.target.files?.[0]; if (!file) return; - const adapter = getAdapterByFilename(file.name); - if (!adapter) { - console.error("Unsupported file type"); - return; - } - - const reader = new FileReader(); - reader.onload = async (e) => { - const content = e.target?.result as ArrayBuffer; - if (!content || !editor) return; - - const confirmImport = () => { - adapter.import(content, editor); - editor.commands.focus(); // Required to trigger pagination recompute - }; - - importFilePopup(userContext, confirmImport); + const confirmImport = async () => { + try { + await importFileIntoProject(file, editor, titlePageEditor, repository); + } catch (error) { + console.error("Import failed:", error); + } }; - reader.readAsArrayBuffer(file); + importFilePopup(userContext, confirmImport); event.target.value = ""; // Reset input so the same file can be selected again if needed }; diff --git a/components/dashboard/project/LayoutSettings.module.css b/components/dashboard/project/LayoutSettings.module.css index 8ff6fe4..dd88536 100644 --- a/components/dashboard/project/LayoutSettings.module.css +++ b/components/dashboard/project/LayoutSettings.module.css @@ -75,6 +75,15 @@ gap: 8px; } +.labelRow { + display: flex; + gap: 16px; +} + +.labelRow > div { + flex: 1; +} + .marginRow { display: flex; align-items: center; @@ -98,12 +107,14 @@ .marginInputWrapper { display: flex; align-items: center; - gap: 8px; background: var(--main-bg); border: 1px solid var(--separator); border-radius: 8px; - padding: 2px 10px; + padding: 0 2px; transition: border-color 0.2s ease; + gap: 0; + height: 40px; + box-sizing: border-box; } .marginInputWrapper:focus-within { @@ -113,35 +124,67 @@ .marginIcon { color: var(--secondary-text); flex-shrink: 0; + margin: 0 4px; } .marginInput { - width: 45px !important; + width: 36px !important; text-align: center; - padding: 6px 4px !important; + padding: 0 !important; font-size: 0.95rem; border: none !important; background: transparent !important; outline: none !important; box-shadow: none !important; color: var(--primary-text); + height: 100%; } -/* Hide number input spinners */ +/* Hide native number spinners */ .marginInput::-webkit-outer-spin-button, .marginInput::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + .marginInput[type="number"] { -moz-appearance: textfield; } +.marginStepGroup { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0; +} + +.marginStepBtn { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 10px; + border-radius: 2px; + border: none; + background: transparent; + color: var(--secondary-text); + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.marginStepBtn:hover { + background: var(--secondary); + color: var(--primary-text); +} + .marginUnit { - font-size: 0.85rem; + font-size: 0.8rem; color: var(--secondary-text); user-select: none; font-weight: 500; + margin-left: 2px; + margin-right: 4px; } .styleGroup { @@ -150,7 +193,10 @@ background: var(--main-bg); border: 1px solid var(--separator); border-radius: 8px; - padding: 4px; + padding: 0 4px; + height: 40px; + align-items: center; + box-sizing: border-box; } .styleBtn { diff --git a/components/dashboard/project/LayoutSettings.tsx b/components/dashboard/project/LayoutSettings.tsx index 7c142ae..2a76bba 100644 --- a/components/dashboard/project/LayoutSettings.tsx +++ b/components/dashboard/project/LayoutSettings.tsx @@ -5,8 +5,10 @@ import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { DEFAULT_ELEMENT_MARGINS, + DEFAULT_PAGE_MARGINS, ElementMargin, ElementStyle, + PageMargin, DEFAULT_ELEMENT_STYLES, } from "@src/lib/project/project-state"; import { PageFormat } from "@src/lib/utils/enums"; @@ -17,8 +19,15 @@ import { Italic, Underline, AlignCenter, + CaseSensitive, ArrowLeftToLine, ArrowRightToLine, + ArrowUpToLine, + ArrowDownToLine, + ChevronUp, + ChevronDown, + Save, + SeparatorHorizontal, } from "lucide-react"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; @@ -30,9 +39,12 @@ import optionCard from "./OptionCard.module.css"; const LayoutSettings = () => { const t = useTranslations("layout"); const tCommon = useTranslations("common"); + const context = useContext(ProjectContext); const { pageFormat, setPageFormat, + pageMargins, + setPageMargins, displaySceneNumbers, setDisplaySceneNumbers, sceneHeadingSpacing, @@ -47,46 +59,122 @@ const LayoutSettings = () => { setElementMargins, elementStyles, setElementStyles, - } = useContext(ProjectContext); + } = context; - // Strip wrapping parentheses for display — the system stores "(CONT'D)" but the - // user should only type the inner text; parentheses are added back on commit. + const MARGIN_ELEMENTS = [ + "scene", + "action", + "character", + "dialogue", + "parenthetical", + "transition", + "section", + ] as const; + + // Strip wrapping parentheses for display const stripParens = (s: string) => (s.startsWith("(") && s.endsWith(")") ? s.slice(1, -1) : s); - // Local state for the continuation input to avoid triggering editor.commands.focus() - // on every keystroke (which steals focus from the input and causes freezes) + // --- LOCAL STATE --- + const [localFormat, setLocalFormat] = useState(pageFormat); + const initialPageMargins = useMemo(() => ({ ...DEFAULT_PAGE_MARGINS, ...pageMargins }), [pageMargins]); + const [localPageMargins, setLocalPageMargins] = useState(initialPageMargins); + const [localDisplaySceneNumbers, setLocalDisplaySceneNumbers] = useState(displaySceneNumbers); + const [localSceneNumberOnRight, setLocalSceneNumberOnRight] = useState(sceneNumberOnRight); + const [localHeadingSpacing, setLocalHeadingSpacing] = useState(sceneHeadingSpacing); const [localContdLabel, setLocalContdLabel] = useState(() => stripParens(contdLabel)); const [localMoreLabel, setLocalMoreLabel] = useState(() => stripParens(moreLabel)); - const hasContdChanges = `(${localContdLabel})` !== contdLabel; - const hasMoreChanges = `(${localMoreLabel})` !== moreLabel; - - const commitContdLabel = () => { - if (hasContdChanges) setContdLabel(`(${localContdLabel})`); - }; + // Merge persisted margins with defaults + const initialMargins = useMemo(() => { + const merged: Record = {}; + for (const key of MARGIN_ELEMENTS) { + merged[key] = elementMargins[key] ?? DEFAULT_ELEMENT_MARGINS[key]; + } + return merged; + }, [elementMargins]); + const [localMargins, setLocalMargins] = useState(initialMargins); - const commitMoreLabel = () => { - if (hasMoreChanges) setMoreLabel(`(${localMoreLabel})`); - }; + // Merge persisted styles with defaults + const initialStyles = useMemo(() => { + const merged: Record = {}; + for (const key of MARGIN_ELEMENTS) { + merged[key] = { + ...(DEFAULT_ELEMENT_STYLES[key] || {}), + ...(elementStyles[key] || {}), + }; + } + return merged; + }, [elementStyles]); + const [localStyles, setLocalStyles] = useState(initialStyles); - // Keep local state in sync when the context value changes externally (e.g. collaboration) + // Sync when context changes externally useEffect(() => { + setLocalFormat(pageFormat); + setLocalPageMargins(initialPageMargins); + setLocalDisplaySceneNumbers(displaySceneNumbers); + setLocalSceneNumberOnRight(sceneNumberOnRight); + setLocalHeadingSpacing(sceneHeadingSpacing); setLocalContdLabel(stripParens(contdLabel)); - }, [contdLabel]); - - useEffect(() => { setLocalMoreLabel(stripParens(moreLabel)); - }, [moreLabel]); + setLocalMargins(initialMargins); + setLocalStyles(initialStyles); + }, [ + pageFormat, + initialPageMargins, + displaySceneNumbers, + sceneNumberOnRight, + sceneHeadingSpacing, + contdLabel, + moreLabel, + initialMargins, + initialStyles, + ]); + + const hasChanges = useMemo(() => { + return ( + localFormat !== pageFormat || + JSON.stringify(localPageMargins) !== JSON.stringify(initialPageMargins) || + localDisplaySceneNumbers !== displaySceneNumbers || + localSceneNumberOnRight !== sceneNumberOnRight || + localHeadingSpacing !== sceneHeadingSpacing || + `(${localContdLabel})` !== contdLabel || + `(${localMoreLabel})` !== moreLabel || + JSON.stringify(localMargins) !== JSON.stringify(initialMargins) || + JSON.stringify(localStyles) !== JSON.stringify(initialStyles) + ); + }, [ + localFormat, + pageFormat, + localPageMargins, + initialPageMargins, + localDisplaySceneNumbers, + displaySceneNumbers, + localSceneNumberOnRight, + sceneNumberOnRight, + localHeadingSpacing, + sceneHeadingSpacing, + localContdLabel, + contdLabel, + localMoreLabel, + moreLabel, + localMargins, + initialMargins, + localStyles, + initialStyles, + ]); + + const handleSave = () => { + setPageFormat(localFormat); + setPageMargins(localPageMargins); + setDisplaySceneNumbers(localDisplaySceneNumbers); + setSceneNumberOnRight(localSceneNumberOnRight); + setSceneHeadingSpacing(localHeadingSpacing); + setContdLabel(`(${localContdLabel})`); + setMoreLabel(`(${localMoreLabel})`); + setElementMargins(localMargins); + setElementStyles(localStyles); + }; - const MARGIN_ELEMENTS = [ - "scene", - "action", - "character", - "dialogue", - "parenthetical", - "transition", - "section", - ] as const; const [selectedElement, setSelectedElement] = useState<(typeof MARGIN_ELEMENTS)[number]>("scene"); const elementOptions: DropdownOption[] = useMemo( @@ -98,22 +186,6 @@ const LayoutSettings = () => { [t], ); - // Merge persisted margins with defaults so inputs always show a value - const mergedMargins = useMemo(() => { - const merged: Record = {}; - for (const key of MARGIN_ELEMENTS) { - merged[key] = elementMargins[key] ?? DEFAULT_ELEMENT_MARGINS[key]; - } - return merged; - }, [elementMargins]); - - const [localMargins, setLocalMargins] = useState(mergedMargins); - - // Sync local state when context changes externally (e.g. collaboration) - useEffect(() => { - setLocalMargins(mergedMargins); - }, [mergedMargins]); - const updateLocalMargin = (element: string, side: "left" | "right", value: string) => { const num = parseFloat(value); if (isNaN(num) || num < 0) return; @@ -123,26 +195,26 @@ const LayoutSettings = () => { })); }; - const commitMargins = () => { - setElementMargins(localMargins); + const stepMargin = (element: string, side: "left" | "right", step: number) => { + const currentValue = localMargins[element]?.[side] ?? DEFAULT_ELEMENT_MARGINS[element][side]; + const newValue = Math.max(0, parseFloat((currentValue + step).toFixed(2))); + setLocalMargins((prev) => ({ + ...prev, + [element]: { ...prev[element], [side]: newValue }, + })); }; - const mergedStyles = useMemo(() => { - const merged: Record = {}; - for (const key of MARGIN_ELEMENTS) { - merged[key] = { - ...(DEFAULT_ELEMENT_STYLES[key] || {}), - ...(elementStyles[key] || {}), - }; - } - return merged; - }, [elementStyles]); - - const [localStyles, setLocalStyles] = useState(mergedStyles); + const updatePageMargin = (side: keyof PageMargin, value: string) => { + const num = parseFloat(value); + if (isNaN(num) || num < 0) return; + setLocalPageMargins((prev) => ({ ...prev, [side]: num })); + }; - useEffect(() => { - setLocalStyles(mergedStyles); - }, [mergedStyles]); + const stepPageMargin = (side: keyof PageMargin, step: number) => { + const currentValue = localPageMargins[side]; + const newValue = Math.max(0, parseFloat((currentValue + step).toFixed(2))); + setLocalPageMargins((prev) => ({ ...prev, [side]: newValue })); + }; const updateLocalStyle = (e: React.MouseEvent, element: string, styleKey: keyof ElementStyle, value: any) => { e.preventDefault(); @@ -158,7 +230,6 @@ const LayoutSettings = () => { [element]: { ...currentStyle, [styleKey]: value }, }; setLocalStyles(newStyles); - setElementStyles(newStyles); }; const renderElementConfig = (element: (typeof MARGIN_ELEMENTS)[number]) => { @@ -173,35 +244,59 @@ const LayoutSettings = () => { {t("margins")}
- + updateLocalMargin(element, "left", e.target.value)} - onBlur={commitMargins} - onKeyDown={(e) => { - if (e.key === "Enter") commitMargins(); - }} className={`${sharedStyles.input} ${styles.marginInput}`} /> +
+ + +
in
- + updateLocalMargin(element, "right", e.target.value)} - onBlur={commitMargins} - onKeyDown={(e) => { - if (e.key === "Enter") commitMargins(); - }} className={`${sharedStyles.input} ${styles.marginInput}`} /> +
+ + +
in
@@ -234,6 +329,14 @@ const LayoutSettings = () => { > + @@ -266,14 +369,24 @@ const LayoutSettings = () => { + +
+ {t("startNewPage")} +
+ +
+
); }; - const handleFormatChange = (newFormat: PageFormat) => { - setPageFormat(newFormat); - }; - const pageFormatOptions: DropdownOption[] = [ { value: "LETTER", label: 'US Letter (8.5" x 11")' }, { value: "A4", label: "A4 (210mm x 297mm)" }, @@ -284,62 +397,170 @@ const LayoutSettings = () => {
handleFormatChange(value as PageFormat)} + value={localFormat} + onChange={(value) => setLocalFormat(value as PageFormat)} options={pageFormatOptions} className={`${sharedStyles.input} ${styles.input}`} />

- {pageFormat === "LETTER" && t("pageFormatHelp.letter")} - {pageFormat === "A4" && t("pageFormatHelp.a4")} + {localFormat === "LETTER" && t("pageFormatHelp.letter")} + {localFormat === "A4" && t("pageFormatHelp.a4")}

+
- -
- setLocalContdLabel(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitContdLabel(); - }} - className={`${sharedStyles.input} ${styles.input}`} - placeholder="CONT'D" - /> - +
+ {t("vertical")} +
+
+ + updatePageMargin("top", e.target.value)} + className={`${sharedStyles.input} ${styles.marginInput}`} + /> +
+ + +
+ in +
+
+ + updatePageMargin("bottom", e.target.value)} + className={`${sharedStyles.input} ${styles.marginInput}`} + /> +
+ + +
+ in +
+
+
+
+ {t("horizontal")} +
+
+ + updatePageMargin("left", e.target.value)} + className={`${sharedStyles.input} ${styles.marginInput}`} + /> +
+ + +
+ in +
+
+ + updatePageMargin("right", e.target.value)} + className={`${sharedStyles.input} ${styles.marginInput}`} + /> +
+ + +
+ in +
+
-
- -
- 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;
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(null); const [isImporting, setIsImporting] = useState(false); const t = useTranslations("projects"); @@ -32,7 +33,7 @@ const EmptyProjectPage = ({ setIsCreating }: Props) => { const result = await importFileAsProject(file, user); if (result.success && result.projectId) { - redirectScreenplay(result.projectId); + router.push(`/projects?projectId=${result.projectId}`); } else { console.error("Import failed:", result.error); } diff --git a/components/projects/ProjectPageContainer.tsx b/components/projects/ProjectPageContainer.tsx index fbac537..45eabb6 100644 --- a/components/projects/ProjectPageContainer.tsx +++ b/components/projects/ProjectPageContainer.tsx @@ -57,6 +57,7 @@ const ProjectPageContainer = () => { setIsImporting(true); try { + // This now correctly preserves all project data (title page, board, etc.) const result = await importFileAsProject(file, user); if (result.success && result.projectId) { diff --git a/components/utils/Form.module.css b/components/utils/Form.module.css index ed0ca69..3f1571f 100644 --- a/components/utils/Form.module.css +++ b/components/utils/Form.module.css @@ -69,6 +69,10 @@ } .btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; font-size: 1rem; color: var(--primary-text); background-color: var(--secondary); diff --git a/messages/de.json b/messages/de.json index edad98c..7644cb8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6,8 +6,8 @@ "synced": "Mit der Cloud synchronisiert", "noConnection": "Keine Verbindung", "reconnecting": "Verbindung wird hergestellt...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "Endlos-Scroll", + "toggleComments": "Kommentare ein-/ausblenden" }, "common": { "save": "Änderungen speichern", @@ -242,7 +242,7 @@ "a4": "Internationales Standardformat. In Europa und den meisten anderen Ländern üblich." }, "sceneHeadings": "Szenenüberschriften", - "bold": "Bold", + "bold": "Fett", "boldDesc": "Szenenüberschriften werden fett dargestellt", "extraSpace": "Zusätzlicher Abstand oben", "extraSpaceDesc": "Zusätzlichen Abstand vor Szenenüberschriften einfügen", @@ -251,11 +251,16 @@ "duplicateRight": "Im rechten Rand duplizieren", "duplicateRightDesc": "Nummer auf beiden Seiten anzeigen", "continuation": "Fortsetzung", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) Bezeichnung", + "contdTitle": "(CONT'D) Bezeichnung", + "pageMargins": "Seitenränder", + "vertical": "Vertikal", + "horizontal": "Horizontal", + "marginTop": "Oben", + "marginBottom": "Unten", "margins": "Ränder", - "marginLeft": "L", - "marginRight": "R", + "marginLeft": "Links", + "marginRight": "Rechts", "marginElements": { "action": "Handlung", "scene": "Szenenüberschrift", @@ -265,16 +270,18 @@ "transition": "Übergang", "section": "Abschnitt" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "Elementkonfiguration", + "style": "Stil", + "alignment": "Ausrichtung", + "italic": "Kursiv", + "underline": "Unterstrichen", + "uppercase": "Großbuchstaben", + "alignLeft": "Links", + "alignCenter": "Mitte", + "alignRight": "Rechts", + "sceneSpacing": "Abstand", + "sceneSpacingDesc": "Abstand über Szenenüberschriften", + "startNewPage": "Neue Seite beginnen" }, "projectSettings": { "titleLabel": "Titel", @@ -347,4 +354,4 @@ "titlePlaceholder": "Titel", "descriptionPlaceholder": "Beschreibung" } -} \ No newline at end of file +} diff --git a/messages/en.json b/messages/en.json index 1442210..3b3265c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10,7 +10,7 @@ "toggleComments": "Toggle Comments" }, "common": { - "save": "Save Changes", + "save": "Save", "cancel": "Cancel", "loading": "Loading..." }, @@ -42,7 +42,7 @@ "reset": "Reset", "resetTitle": "Clear user binding", "resetDefaults": "Reset to defaults", - "save": "Save changes" + "save": "Save" }, "language": { "label": "Display Language", @@ -253,9 +253,14 @@ "continuation": "Continuation", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Page Margins", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "Top", + "marginBottom": "Bottom", "margins": "Margins", - "marginLeft": "L", - "marginRight": "R", + "marginLeft": "Left", + "marginRight": "Right", "marginElements": { "action": "Action", "scene": "Scene Heading", @@ -270,11 +275,13 @@ "alignment": "Alignment", "italic": "Italic", "underline": "Underline", + "uppercase": "Uppercase", "alignLeft": "Left", "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Start New Page" }, "projectSettings": { "titleLabel": "Title", @@ -286,7 +293,7 @@ "posterLabel": "Poster", "noPoster": "No Poster", "posterHelp": "Recommended: 600x900 pixels (2:3 ratio). Supports PNG, JPG.", - "saveChanges": "Save changes", + "saveChanges": "Save", "dangerZoneTitle": "Danger zone" }, "contextMenu": { @@ -347,4 +354,4 @@ "titlePlaceholder": "Title", "descriptionPlaceholder": "Description" } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index c18d3de..591c1cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6,8 +6,8 @@ "synced": "Sincronizado en la nube", "noConnection": "Sin conexión", "reconnecting": "Reconectando...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "Desplazamiento infinito", + "toggleComments": "Mostrar/ocultar comentarios" }, "common": { "save": "Guardar cambios", @@ -242,7 +242,7 @@ "a4": "Formato estándar internacional. Común en Europa y la mayoría de los países." }, "sceneHeadings": "Encabezados de escena", - "bold": "Bold", + "bold": "Negrita", "boldDesc": "Los encabezados de escena aparecerán en negrita", "extraSpace": "Espacio extra arriba", "extraSpaceDesc": "Añadir espacio extra antes de los encabezados de escena", @@ -251,11 +251,16 @@ "duplicateRight": "Duplicar en margen derecho", "duplicateRightDesc": "Mostrar número en ambos lados", "continuation": "Continuación", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) Etiqueta", + "contdTitle": "(CONT'D) Etiqueta", + "pageMargins": "Márgenes de página", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "Superior", + "marginBottom": "Inferior", "margins": "Márgenes", - "marginLeft": "L", - "marginRight": "R", + "marginLeft": "Izquierda", + "marginRight": "Derecha", "marginElements": { "action": "Acción", "scene": "Encabezado de escena", @@ -265,16 +270,18 @@ "transition": "Transición", "section": "Sección" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "Configuración de elementos", + "style": "Estilo", + "alignment": "Alineación", + "italic": "Cursiva", + "underline": "Subrayado", + "uppercase": "Mayúsculas", + "alignLeft": "Izquierda", + "alignCenter": "Centro", + "alignRight": "Derecha", + "sceneSpacing": "Espaciado", + "sceneSpacingDesc": "Espaciado sobre los encabezados de escena", + "startNewPage": "Iniciar nueva página" }, "projectSettings": { "titleLabel": "Título", @@ -347,4 +354,4 @@ "titlePlaceholder": "Título", "descriptionPlaceholder": "Descripción" } -} \ No newline at end of file +} diff --git a/messages/fr.json b/messages/fr.json index 3d80a5d..bd358fd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6,8 +6,8 @@ "synced": "Synchronisé dans le cloud", "noConnection": "Pas de connexion", "reconnecting": "Reconnexion...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "Défilement infini", + "toggleComments": "Afficher/masquer les commentaires" }, "common": { "save": "Enregistrer", @@ -242,7 +242,7 @@ "a4": "Format international standard. Courant en Europe et dans la plupart des pays." }, "sceneHeadings": "En-têtes de scène", - "bold": "Bold", + "bold": "Gras", "boldDesc": "Les en-têtes de scène apparaîtront en gras", "extraSpace": "Espace supplémentaire au-dessus", "extraSpaceDesc": "Ajouter un espacement supplémentaire avant les en-têtes de scène", @@ -251,11 +251,16 @@ "duplicateRight": "Dupliquer dans la marge droite", "duplicateRightDesc": "Afficher le numéro des deux côtés", "continuation": "Continuation", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) Étiquette", + "contdTitle": "(CONT'D) Étiquette", + "pageMargins": "Marges de page", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "Haut", + "marginBottom": "Bas", "margins": "Marges", - "marginLeft": "G", - "marginRight": "D", + "marginLeft": "Gauche", + "marginRight": "Droite", "marginElements": { "action": "Action", "scene": "En-tête de scène", @@ -265,16 +270,18 @@ "transition": "Transition", "section": "Section" }, - "elements": "Element Configuration", + "elements": "Configuration des éléments", "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "alignment": "Alignement", + "italic": "Italique", + "underline": "Souligné", + "uppercase": "Majuscules", + "alignLeft": "Gauche", + "alignCenter": "Centré", + "alignRight": "Droite", + "sceneSpacing": "Espacement", + "sceneSpacingDesc": "Espacement au-dessus des en-têtes de scène", + "startNewPage": "Commencer une nouvelle page" }, "projectSettings": { "titleLabel": "Titre", @@ -347,4 +354,4 @@ "titlePlaceholder": "Titre", "descriptionPlaceholder": "Description" } -} \ No newline at end of file +} diff --git a/messages/ja.json b/messages/ja.json index 5493286..1599482 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -6,8 +6,8 @@ "synced": "クラウドに同期済み", "noConnection": "接続なし", "reconnecting": "再接続中...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "エンドレススクロール", + "toggleComments": "コメントの表示切替" }, "common": { "save": "変更を保存", @@ -242,7 +242,7 @@ "a4": "国際標準形式。ヨーロッパやほとんどの国で一般的。" }, "sceneHeadings": "シーン見出し", - "bold": "Bold", + "bold": "太字", "boldDesc": "シーン見出しが太字で表示されます", "extraSpace": "上部に余白を追加", "extraSpaceDesc": "シーン見出しの前に追加のスペースを挿入します", @@ -251,11 +251,16 @@ "duplicateRight": "右余白にも複製", "duplicateRightDesc": "両側に番号を表示", "continuation": "継続", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) ラベル", + "contdTitle": "(CONT'D) ラベル", + "pageMargins": "ページ余白", + "vertical": "垂直", + "horizontal": "水平", + "marginTop": "上部", + "marginBottom": "下部", "margins": "余白", - "marginLeft": "左", - "marginRight": "右", + "marginLeft": "左側", + "marginRight": "右側", "marginElements": { "action": "アクション", "scene": "シーン見出し", @@ -265,16 +270,18 @@ "transition": "トランジション", "section": "セクション" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "要素の設定", + "style": "スタイル", + "alignment": "配置", + "italic": "斜体", + "underline": "下線", + "uppercase": "大文字", + "alignLeft": "左揃え", + "alignCenter": "中央揃え", + "alignRight": "右揃え", + "sceneSpacing": "間隔", + "sceneSpacingDesc": "シーン見出しの上の間隔", + "startNewPage": "新しいページで開始" }, "projectSettings": { "titleLabel": "タイトル", @@ -347,4 +354,4 @@ "titlePlaceholder": "タイトル", "descriptionPlaceholder": "説明" } -} \ No newline at end of file +} diff --git a/messages/ko.json b/messages/ko.json index 50cb796..63e7047 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6,8 +6,8 @@ "synced": "클라우드에 동기화됨", "noConnection": "연결 없음", "reconnecting": "재연결 중...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "무한 스크롤", + "toggleComments": "댓글 표시/숨기기" }, "common": { "save": "변경사항 저장", @@ -242,7 +242,7 @@ "a4": "국제 표준 형식. 유럽 및 대부분의 국가에서 통용됩니다." }, "sceneHeadings": "씬 헤딩", - "bold": "Bold", + "bold": "굵게", "boldDesc": "씬 헤딩이 굵게 표시됩니다", "extraSpace": "위 추가 간격", "extraSpaceDesc": "씬 헤딩 앞에 추가 간격을 삽입합니다", @@ -251,11 +251,16 @@ "duplicateRight": "오른쪽 여백에 복제", "duplicateRightDesc": "양쪽에 번호 표시", "continuation": "이어지기", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) 레이블", + "contdTitle": "(CONT'D) 레이블", + "pageMargins": "페이지 여백", + "vertical": "세로", + "horizontal": "가로", + "marginTop": "위쪽", + "marginBottom": "아래쪽", "margins": "여백", - "marginLeft": "좌", - "marginRight": "우", + "marginLeft": "왼쪽", + "marginRight": "오른쪽", "marginElements": { "action": "액션", "scene": "씬 헤딩", @@ -265,16 +270,18 @@ "transition": "전환", "section": "섹션" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "요소 설정", + "style": "스타일", + "alignment": "정렬", + "italic": "기울임꼴", + "underline": "밑줄", + "uppercase": "대문자", + "alignLeft": "왼쪽", + "alignCenter": "가운데", + "alignRight": "오른쪽", + "sceneSpacing": "간격", + "sceneSpacingDesc": "씬 헤딩 위의 간격", + "startNewPage": "새 페이지에서 시작" }, "projectSettings": { "titleLabel": "제목", @@ -347,4 +354,4 @@ "titlePlaceholder": "제목", "descriptionPlaceholder": "설명" } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index 76e36cd..40b479b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6,8 +6,8 @@ "synced": "Zsynchronizowano z chmurą", "noConnection": "Brak połączenia", "reconnecting": "Łączenie...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "Nieskończone przewijanie", + "toggleComments": "Przełącz komentarze" }, "common": { "save": "Zapisz zmiany", @@ -242,7 +242,7 @@ "a4": "Międzynarodowy format standardowy. Powszechny w Europie i większości krajów." }, "sceneHeadings": "Nagłówki scen", - "bold": "Bold", + "bold": "Pogrubienie", "boldDesc": "Nagłówki scen będą wyświetlane pogrubione", "extraSpace": "Dodatkowe miejsce powyżej", "extraSpaceDesc": "Dodaj dodatkowe odstępy przed nagłówkami scen", @@ -251,11 +251,16 @@ "duplicateRight": "Powiel na prawym marginesie", "duplicateRightDesc": "Wyświetlaj numer po obu stronach", "continuation": "Kontynuacja", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) Etykieta", + "contdTitle": "(CONT'D) Etykieta", + "pageMargins": "Marginesy strony", + "vertical": "Pionowo", + "horizontal": "Poziomo", + "marginTop": "Góra", + "marginBottom": "Dół", "margins": "Marginesy", - "marginLeft": "L", - "marginRight": "P", + "marginLeft": "Lewo", + "marginRight": "Prawo", "marginElements": { "action": "Akcja", "scene": "Nagłówek sceny", @@ -265,16 +270,18 @@ "transition": "Przejście", "section": "Sekcja" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "Konfiguracja elementów", + "style": "Styl", + "alignment": "Wyrównanie", + "italic": "Kursywa", + "underline": "Podkreślenie", + "uppercase": "Wielkie litery", + "alignLeft": "Do lewej", + "alignCenter": "Wyśrodkuj", + "alignRight": "Do prawej", + "sceneSpacing": "Odstęp", + "sceneSpacingDesc": "Odstęp nad nagłówkami scen", + "startNewPage": "Zacznij nową stronę" }, "projectSettings": { "titleLabel": "Tytuł", @@ -347,4 +354,4 @@ "titlePlaceholder": "Tytuł", "descriptionPlaceholder": "Opis" } -} \ No newline at end of file +} diff --git a/messages/zh.json b/messages/zh.json index d84983e..8b54995 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -6,8 +6,8 @@ "synced": "已同步至云端", "noConnection": "无连接", "reconnecting": "正在重新连接...", - "endlessScroll": "Endless Scroll", - "toggleComments": "Toggle Comments" + "endlessScroll": "无限滚动", + "toggleComments": "切换评论显示" }, "common": { "save": "保存更改", @@ -242,7 +242,7 @@ "a4": "国际标准格式,欧洲及大多数国家通用。" }, "sceneHeadings": "场景标题", - "bold": "Bold", + "bold": "粗体", "boldDesc": "场景标题将以粗体显示", "extraSpace": "上方额外空间", "extraSpaceDesc": "在场景标题前添加额外间距", @@ -251,11 +251,16 @@ "duplicateRight": "在右边距重复", "duplicateRightDesc": "两侧均显示编号", "continuation": "续接", - "moreTitle": "(MORE) Label", - "contdTitle": "(CONT'D) Label", + "moreTitle": "(MORE) 标签", + "contdTitle": "(CONT'D) 标签", + "pageMargins": "页面边距", + "vertical": "垂直", + "horizontal": "水平", + "marginTop": "上边距", + "marginBottom": "下边距", "margins": "边距", - "marginLeft": "左", - "marginRight": "右", + "marginLeft": "左边距", + "marginRight": "右边距", "marginElements": { "action": "动作", "scene": "场景标题", @@ -265,16 +270,18 @@ "transition": "转场", "section": "章节" }, - "elements": "Element Configuration", - "style": "Style", - "alignment": "Alignment", - "italic": "Italic", - "underline": "Underline", - "alignLeft": "Left", - "alignCenter": "Center", - "alignRight": "Right", - "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "elements": "元素配置", + "style": "样式", + "alignment": "对齐方式", + "italic": "斜体", + "underline": "下划线", + "uppercase": "大写", + "alignLeft": "左对齐", + "alignCenter": "居中", + "alignRight": "右对齐", + "sceneSpacing": "间距", + "sceneSpacingDesc": "场景标题上方的间距", + "startNewPage": "从新页面开始" }, "projectSettings": { "titleLabel": "标题", @@ -347,4 +354,4 @@ "titlePlaceholder": "标题", "descriptionPlaceholder": "描述" } -} \ No newline at end of file +} diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 37ec763..f6386a6 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { createContext, @@ -21,8 +21,10 @@ import { LayoutData, useProjectYjs, ElementStyle, + PageMargin, + DEFAULT_PAGE_MARGINS, } from "@src/lib/project/project-state"; -import { Comment, Screenplay } from "@src/lib/utils/types"; +import { Screenplay } from "@src/lib/utils/types"; import { ScreenplayElement, TitlePageElement, Style, PageFormat } from "@src/lib/utils/enums"; import { SearchMatch } from "@src/lib/screenplay/extensions/search-highlight-extension"; @@ -74,6 +76,8 @@ export interface ProjectContextType { // Page format pageFormat: PageFormat; setPageFormat: (format: PageFormat) => void; + pageMargins: PageMargin; + setPageMargins: (margins: PageMargin) => void; displaySceneNumbers: boolean; setDisplaySceneNumbers: (display: boolean) => void; sceneHeadingSpacing: number; @@ -99,11 +103,6 @@ export interface ProjectContextType { searchMatches: SearchMatch[]; setSearchMatches: (matches: SearchMatch[]) => void; - // Comments state - comments: Comment[]; - activeCommentId: string | null; - setActiveCommentId: (id: string | null) => void; - // Project metadata (for title page placeholders) projectTitle: string; setProjectTitle: (title: string) => void; @@ -147,6 +146,8 @@ const defaultContextValue: ProjectContextType = { toggleCharacterHighlight: () => {}, pageFormat: "LETTER", setPageFormat: () => {}, + pageMargins: DEFAULT_PAGE_MARGINS, + setPageMargins: () => {}, displaySceneNumbers: false, setDisplaySceneNumbers: () => {}, sceneHeadingSpacing: 1, @@ -183,10 +184,6 @@ const defaultContextValue: ProjectContextType = { setCurrentSearchIndex: () => {}, searchMatches: [], setSearchMatches: () => {}, - // Comments state defaults - comments: [], - activeCommentId: null, - setActiveCommentId: () => {}, // Project metadata defaults projectTitle: "", setProjectTitle: () => {}, @@ -265,6 +262,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [selectedStyles, setSelectedStylesState] = useState