From 4d416f754c7a803889a84b2c04be66582de5bc92 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 12 Mar 2026 17:26:58 +0100 Subject: [PATCH 1/7] fixed undo in pagination extension, removed useless extension --- src/lib/screenplay/editor.ts | 5 - .../extensions/orphan-prevention-extension.ts | 275 ------------------ .../extensions/pagination-extension.ts | 76 +++-- 3 files changed, 55 insertions(+), 301 deletions(-) delete mode 100644 src/lib/screenplay/extensions/orphan-prevention-extension.ts diff --git a/src/lib/screenplay/editor.ts b/src/lib/screenplay/editor.ts index 037d1cd..4f2539f 100644 --- a/src/lib/screenplay/editor.ts +++ b/src/lib/screenplay/editor.ts @@ -33,7 +33,6 @@ import { createSceneBookmarkExtension, refreshSceneBookmarks } from "./extension import { createSceneIdDedupExtension } from "./extensions/scene-id-dedup-extension"; import { CommentMark } from "./extensions/comment-highlight-extension"; import { FountainExtension } from "./extensions/fountain-extension"; -import { OrphanPreventionExtension } from "./extensions/orphan-prevention-extension"; export const applyMarkToggle = (editor: Editor, style: Style) => { if (style & Style.Bold) editor.chain().toggleBold().focus().run(); @@ -435,10 +434,6 @@ export const useScriptioEditor = ( footerRight: "", ...SCREENPLAY_FORMATS[pageSize], }), - OrphanPreventionExtension.configure({ - getContdLabel: () => contdLabelRef.current, - getMoreLabel: () => moreLabelRef.current, - }), KeybindsExtension.configure({ userKeybinds: userKeybinds || {}, onAction: (id, editorInstance) => { diff --git a/src/lib/screenplay/extensions/orphan-prevention-extension.ts b/src/lib/screenplay/extensions/orphan-prevention-extension.ts deleted file mode 100644 index d4c6975..0000000 --- a/src/lib/screenplay/extensions/orphan-prevention-extension.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; - -const pluginKey = new PluginKey("orphanPrevention"); - -declare module "@tiptap/core" { - interface Commands { - orphanPrevention: { - forceOrphanUpdate: () => ReturnType; - }; - } -} - -function yieldToMain(): Promise { - return new Promise((resolve) => { - const { port1, port2 } = new MessageChannel(); - port1.onmessage = () => resolve(); - port2.postMessage(null); - }); -} - -async function isOrphanable(node: HTMLElement): Promise { - return node.classList.contains("character") || node.classList.contains("scene"); -} - -export interface OrphanPreventionOptions { - getContdLabel: () => string; - getMoreLabel: () => string; -} - -export interface OrphanPreventionExtensionOptions { - getContdLabel: () => string; - getMoreLabel: () => string; -} - -async function computeAndDispatch( - view: EditorView, - isCancelled: () => boolean, - options: OrphanPreventionOptions, -): Promise { - const editorDom = view.dom as HTMLElement; - const editorTop = editorDom.getBoundingClientRect().top; - - const gapEls = Array.from(editorDom.querySelectorAll(".breaker")); - if (gapEls.length === 0) { - if (!isCancelled()) view.dispatch(view.state.tr.setMeta(pluginKey, DecorationSet.empty)); - return; - } - - const paragraphs = Array.from(editorDom.children).filter((el) => el.tagName === "P") as HTMLElement[]; - const decorations: Decoration[] = []; - - let lastNodeIdx = 0; - for (const gapEl of gapEls) { - if (isCancelled()) return; - - const breakerRect = gapEl.getBoundingClientRect(); - let breakerTop = breakerRect.top - editorTop; - let lastNode: HTMLElement | null = null; - let lastNodeTop = 0; - let lastNodeHeight = 0; - - for (let i = lastNodeIdx + 1; i < paragraphs.length; i++) { - const pRect = paragraphs[i].getBoundingClientRect(); - const pTop = pRect.top - editorTop; - // We put -6px because when a node starts on next page it sometimes flow up to previous one - // and outranges the breaker top by few pixels, gets detected as last node while it's not. - if (pTop < breakerTop - 6) { - lastNode = paragraphs[i]; - lastNodeIdx = i; - lastNodeTop = pTop; - lastNodeHeight = pRect.height; - } else break; - } - - if (lastNode) { - // Red: node that straddles the page break (debug reference). - try { - const pos = view.posAtDOM(lastNode, 0); - const resolved = view.state.doc.resolve(pos); - const start = resolved.before(resolved.depth); - - const isStraddling = lastNodeTop + lastNodeHeight > breakerTop + 6; - - // Clear labels for this specific gap synchronously to avoid flashing across yields - gapEl.querySelectorAll(".injected-dialogue-label").forEach((el) => el.remove()); - - // Debug visually: Highlight the strictly identified `lastNode` right before the page gap. - decorations.push( - Decoration.node(start, start + resolved.parent.nodeSize, { - /*style: "background-color: rgba(255, 0, 0, 0.2) !important; outline: 2px solid red;",*/ - class: "orphan-debug-last-node", - }), - ); - - // If it's dialogue straddling a page break (actually physically crossing the breaker gap) - if (isStraddling && lastNode.classList.contains("dialogue")) { - const computedStyle = window.getComputedStyle(lastNode); - - const moreElem = document.createElement("div"); - moreElem.className = "injected-dialogue-label"; - moreElem.innerText = options.getMoreLabel(); - moreElem.style.position = "absolute"; - moreElem.style.top = "0px"; - moreElem.style.left = "0px"; - moreElem.style.width = "100%"; - moreElem.style.textAlign = "center"; - moreElem.style.pointerEvents = "none"; - moreElem.style.zIndex = "10"; - - moreElem.style.fontFamily = computedStyle.fontFamily; - moreElem.style.fontSize = computedStyle.fontSize; - moreElem.style.color = computedStyle.color; - moreElem.style.lineHeight = computedStyle.lineHeight; - - // Find the preceding character name - let characterName = ""; - for (let j = lastNodeIdx - 1; j >= 0; j--) { - if (paragraphs[j].classList.contains("character")) { - characterName = paragraphs[j].innerText.trim(); - break; - } - } - - const contElem = document.createElement("div"); - contElem.className = "injected-dialogue-label"; - contElem.innerText = characterName - ? `${characterName} ${options.getContdLabel()}` - : options.getContdLabel(); - contElem.style.position = "absolute"; - contElem.style.bottom = "0px"; - contElem.style.left = "0px"; - contElem.style.width = "100%"; - contElem.style.textAlign = "center"; - contElem.style.pointerEvents = "none"; - contElem.style.zIndex = "10"; - contElem.style.textTransform = "uppercase"; - - contElem.style.fontFamily = computedStyle.fontFamily; - contElem.style.fontSize = computedStyle.fontSize; - contElem.style.lineHeight = computedStyle.lineHeight; - contElem.style.color = computedStyle.color; - - gapEl.appendChild(moreElem); - gapEl.appendChild(contElem); - } else if (isStraddling) { - /*decorations.push( - Decoration.node(start, start + resolved.parent.nodeSize, { - style: "background-color: red;", - }), - );*/ - } - } catch { - // detached or invalid position — skip - } - } - - // Yield between page gaps so queued input events can be processed first. - await yieldToMain(); - } - - if (isCancelled() || (view as any).isDestroyed) return; - view.dispatch(view.state.tr.setMeta(pluginKey, DecorationSet.create(view.state.doc, decorations))); -} - -export const OrphanPreventionExtension = Extension.create({ - name: "orphanPrevention", - - addOptions() { - return { - getContdLabel: () => "(CONT'D)", - getMoreLabel: () => "(MORE)", - }; - }, - - addCommands() { - return { - forceOrphanUpdate: - () => - ({ tr, dispatch }) => { - if (dispatch) { - tr.setMeta(pluginKey, "force-update"); - } - return true; - }, - }; - }, - - addProseMirrorPlugins() { - const options = this.options; - return [ - new Plugin({ - key: pluginKey, - state: { - init: () => ({ decos: DecorationSet.empty, version: 0 }), - apply(tr, old, _, newState) { - const meta = tr.getMeta(pluginKey); - if (meta instanceof DecorationSet) return { decos: meta, version: old.version }; - - let nextDecos = old.decos; - if (tr.docChanged) { - nextDecos = old.decos.map(tr.mapping, newState.doc); - } - - if (meta === "force-update" || tr.docChanged) { - return { decos: nextDecos, version: old.version + 1 }; - } - - return old; - }, - }, - view(view) { - let raf: number | null = null; - let generation = 0; - - const schedule = () => { - if (raf !== null) cancelAnimationFrame(raf); - const gen = ++generation; - raf = requestAnimationFrame(() => { - raf = null; - computeAndDispatch(view, () => generation !== gen, options); - }); - }; - - // Pagination builds its DOM in its own RAF (after ours). Watch for - // gap elements appearing so we can recompute once they exist, then - // disconnect — zero cost after initial mount. - const observer = new MutationObserver(() => { - if ((view.dom as HTMLElement).querySelector(".rm-pagination-gap")) { - observer.disconnect(); // Disconnect to prevent infinite loops from our own DOM mutations - schedule(); - } - }); - observer.observe(view.dom as HTMLElement, { - childList: true, - subtree: true, - }); - - // Ensure the editor has relative positioning so our absolute widgets flow inside it - if (window.getComputedStyle(view.dom).position === "static") { - (view.dom as HTMLElement).style.position = "relative"; - } - - schedule(); - return { - update(view, prev) { - const prevPluginState = pluginKey.getState(prev); - const currPluginState = pluginKey.getState(view.state); - if ( - view.state.doc !== prev.doc || - (prevPluginState && - currPluginState && - prevPluginState.version !== currPluginState.version) - ) { - schedule(); - } - }, - destroy() { - generation++; - observer.disconnect(); - if (raf !== null) cancelAnimationFrame(raf); - }, - }; - }, - props: { - decorations(state) { - const pluginState = this.getState(state); - return pluginState ? pluginState.decos : DecorationSet.empty; - }, - }, - }), - ]; - }, -}); diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 3fdf340..e768f81 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -86,7 +86,7 @@ export interface FooterOptions { footerRight: string; } -export interface PaginationPlusOptions { +export interface PaginationOptions { pageHeight: number; // full physical page height in px pageWidth: number; // full physical page width in px pageGap: number; // visual gap between pages in px @@ -115,7 +115,7 @@ export interface PageBreakInfo { declare module "@tiptap/core" { interface Commands { - PaginationPlus: { + Pagination: { updatePageSize: (size: Partial) => ReturnType; updatePageHeight: (height: number) => ReturnType; updatePageWidth: (width: number) => ReturnType; @@ -132,7 +132,7 @@ declare module "@tiptap/core" { // Default options // --------------------------------------------------------------------------- -const defaultOptions: PaginationPlusOptions = { +const defaultOptions: PaginationOptions = { pageHeight: 1060, pageWidth: 818, pageGap: 40, @@ -155,7 +155,7 @@ const defaultOptions: PaginationPlusOptions = { // Helpers // --------------------------------------------------------------------------- -function syncVars(dom: HTMLElement, o: PaginationPlusOptions) { +function syncVars(dom: HTMLElement, o: PaginationOptions) { const vars: Record = { "page-height": `${o.pageHeight}px`, "page-width": `${o.pageWidth}px`, @@ -175,7 +175,7 @@ function syncVars(dom: HTMLElement, o: PaginationPlusOptions) { // Decoration builders // --------------------------------------------------------------------------- -function renderHeader(pagenum: number, options: PaginationPlusOptions): string { +function renderHeader(pagenum: number, options: PaginationOptions): string { const custom = options.customHeader[pagenum]; const left = custom?.headerLeft ?? options.headerLeft; const right = (custom?.headerRight ?? options.headerRight).replace("{page}", `${pagenum}`); @@ -185,7 +185,7 @@ function renderHeader(pagenum: number, options: PaginationPlusOptions): string { ); } -function renderFooter(pagenum: number, options: PaginationPlusOptions): string { +function renderFooter(pagenum: number, options: PaginationOptions): string { const custom = options.customFooter[pagenum]; const left = custom?.footerLeft ?? options.footerLeft; const right = (custom?.footerRight ?? options.footerRight).replace("{page}", `${pagenum}`); @@ -195,7 +195,7 @@ function renderFooter(pagenum: number, options: PaginationPlusOptions): string { ); } -function createFirstPageWidget(options: PaginationPlusOptions): HTMLElement { +function createFirstPageWidget(options: PaginationOptions): HTMLElement { const container = document.createElement("div"); container.className = "pagination-first-page"; container.contentEditable = "false"; @@ -234,7 +234,7 @@ function getSplitPaddingVars(nodeType: ScreenplayElement): [string, string] { } } -function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationPlusOptions): HTMLElement { +function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOptions): HTMLElement { const container = document.createElement("div"); container.className = "pagination-page-break"; container.contentEditable = "false"; @@ -312,7 +312,7 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationPlus return container; } -function createLastPageWidget(pagenum: number, freespace: number, options: PaginationPlusOptions): HTMLElement { +function createLastPageWidget(pagenum: number, freespace: number, options: PaginationOptions): HTMLElement { const container = document.createElement("div"); container.className = "pagination-last-page"; container.contentEditable = "false"; @@ -342,7 +342,7 @@ function buildDecorations( doc: any, breaks: PageBreakInfo[], lastPageFreespace: number, - options: PaginationPlusOptions, + options: PaginationOptions, ): DecorationSet { const decorations: Decoration[] = []; @@ -384,19 +384,33 @@ function buildDecorations( // Height measurement // --------------------------------------------------------------------------- +const heightCache = new Map(); + const getHTMLHeight = ( domNode: HTMLElement, editorDom: HTMLElement, nodeType: string, - options: PaginationPlusOptions, + options: PaginationOptions, ): number => { + const textContent = domNode.textContent || ""; + const cacheKey = `${nodeType}:${options.pageWidth}:${options.marginLeft}:${options.marginRight}:${textContent}`; + + if (heightCache.has(cacheKey)) { + return heightCache.get(cacheKey)!; + } + let testDiv = setupTestDiv(editorDom, options); testDiv.innerHTML = domNode.outerHTML; const rect = testDiv.getBoundingClientRect(); - return Math.round(rect.height); + const height = Math.round(rect.height); + + if (heightCache.size > 10000) heightCache.clear(); + heightCache.set(cacheKey, height); + + return height; }; -const setupTestDiv = (editorDom: HTMLElement, options: PaginationPlusOptions): HTMLElement => { +const setupTestDiv = (editorDom: HTMLElement, options: PaginationOptions): HTMLElement => { let testDiv = document.getElementById("pagination-test-div"); if (!testDiv) { testDiv = document.createElement("div"); @@ -460,7 +474,7 @@ function trySplitNode( freespace: number, nodeElement: HTMLElement, editorDOM: HTMLElement, - options: PaginationPlusOptions, + options: PaginationOptions, ): SplitResult | null { if (!sentenceSegmenter) return null; @@ -527,11 +541,15 @@ const createPaginationPlugin = (extension: any) => lastPageFreespace: 0, }), apply(tr, value: PaginationState, oldState, newState): PaginationState { - const options = extension.options as PaginationPlusOptions; + const options = extension.options as PaginationOptions; const heightUpdate = tr.getMeta("heightUpdate"); const formatUpdate = tr.getMeta("pageFormatUpdate"); const forceUpdate = tr.getMeta("forcePaginationUpdate"); + if (forceUpdate || formatUpdate) { + heightCache.clear(); + } + // Nothing pagination-related changed if (!tr.docChanged && !forceUpdate && !formatUpdate) return value; @@ -777,18 +795,34 @@ const createPaginationPlugin = (extension: any) => const state = paginationKey.getState(newState) as PaginationState | undefined; if (!state?.heightUpdates.length) return; + // Skip processing for remote Yjs transactions + const isRemoteChange = transactions.some((tr) => tr.getMeta("y-sync$")); + if (isRemoteChange) { + return null; + } + const tr = newState.tr; tr.setMeta("heightUpdate", true); - tr.setMeta("addToHistory", false); + + // If the original transactions were meant to be excluded from history (e.g. undo/redo), + // ensure the height updates are also excluded so they don't pollute the history stack. + if (transactions.some((t) => t.getMeta("addToHistory") === false)) { + tr.setMeta("addToHistory", false); + } state.heightUpdates.forEach(({ pos, height }) => { - const node = newState.doc.nodeAt(pos); + const mappedPos = tr.mapping.map(pos); + const node = tr.doc.nodeAt(mappedPos); if (node && node.attrs.height !== height) { - tr.setNodeMarkup(pos, undefined, { ...node.attrs, height }); + tr.setNodeMarkup(mappedPos, undefined, { ...node.attrs, height }); } }); - return tr.steps.length ? tr : null; + if (tr.steps.length) { + return tr; + } + + return null; }, props: { decorations(state) { @@ -801,8 +835,8 @@ const createPaginationPlugin = (extension: any) => // Extension // --------------------------------------------------------------------------- -export const ScriptioPagination = Extension.create({ - name: "PaginationPlus", +export const ScriptioPagination = Extension.create({ + name: "Pagination", addOptions() { return defaultOptions; From db9d6b80ecade8951c24ab2c60f2a365a2292c86 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Fri, 13 Mar 2026 18:55:14 +0100 Subject: [PATCH 2/7] refactoring editor hook usage --- components/editor/CommentCards.tsx | 35 +- components/editor/DocumentEditorPanel.tsx | 376 +++++++++++++++++ components/editor/EditorPanel.tsx | 315 +------------- components/editor/TitlePagePanel.tsx | 107 +++-- components/editor/sidebar/ContextMenu.tsx | 25 +- src/context/ProjectContext.tsx | 37 +- src/lib/editor/document-editor-config.ts | 96 +++++ src/lib/editor/use-document-comments.ts | 96 +++++ src/lib/editor/use-document-editor.ts | 475 +++++++++++++++++++++ src/lib/project/project-repository.ts | 67 ++- src/lib/screenplay/editor.ts | 487 +--------------------- src/lib/titlepage/editor.ts | 198 +-------- 12 files changed, 1227 insertions(+), 1087 deletions(-) create mode 100644 components/editor/DocumentEditorPanel.tsx create mode 100644 src/lib/editor/document-editor-config.ts create mode 100644 src/lib/editor/use-document-comments.ts create mode 100644 src/lib/editor/use-document-editor.ts 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>(new Map()); @@ -428,19 +445,15 @@ const CommentCards = () => { onActivate={() => setActiveCommentId(comment.id)} onDeactivate={() => setActiveCommentId(null)} onSave={(text: string) => { - repository?.updateComment(comment.id, { text }); + onUpdateComment(comment.id, { text }); }} onDelete={() => { editor?.commands.unsetComment(comment.id); - repository?.deleteComment(comment.id); + onDeleteComment(comment.id); setActiveCommentId(null); }} onReply={(text: string) => { - repository?.addReply(comment.id, { - text, - author: user?.username || "Anonymous", - createdAt: Date.now(), - }); + onAddReply(comment.id, text, user?.username || "Anonymous"); }} /> diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx new file mode 100644 index 0000000..1f581de --- /dev/null +++ b/components/editor/DocumentEditorPanel.tsx @@ -0,0 +1,376 @@ +"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 } from "@src/lib/screenplay/editor"; +import { ScreenplayElement } from "@src/lib/utils/enums"; +import { 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, + 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]; + 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`); + } + 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"); + } + + if (isVisible) { + editor.commands.focus(); + } + }, [editor, isVisible, config.type, displaySceneNumbers, sceneHeadingSpacing, sceneNumberOnRight, contdLabel, moreLabel, elementMargins, elementStyles]); + + // ---- 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: { + 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)) { + 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/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 37ec763..22aef22 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { createContext, @@ -22,7 +22,7 @@ import { useProjectYjs, ElementStyle, } 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"; @@ -99,11 +99,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; @@ -183,10 +178,6 @@ const defaultContextValue: ProjectContextType = { setCurrentSearchIndex: () => {}, searchMatches: [], setSearchMatches: () => {}, - // Comments state defaults - comments: [], - activeCommentId: null, - setActiveCommentId: () => {}, // Project metadata defaults projectTitle: "", setProjectTitle: () => {}, @@ -291,10 +282,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [currentSearchIndex, setCurrentSearchIndexState] = useState(0); const [searchMatches, setSearchMatchesState] = useState([]); - // Comments state - const [comments, setComments] = useState([]); - const [activeCommentId, setActiveCommentIdState] = useState(null); - // Project metadata state (for title page placeholders) const [projectTitle, setProjectTitleState] = useState(""); const [projectAuthor, setProjectAuthorState] = useState(""); @@ -415,13 +402,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = updateScenes(allScenes); }); - // Observe comments changes - const initialComments = Object.values(repository.comments); - setComments(initialComments); - const unsubscribeComments = repository.observeComments((commentsMap) => { - setComments(Object.values(commentsMap)); - }); - // Observe metadata changes (for title page placeholders) const initialTitle = repository.getTitle(); const initialAuthor = repository.getAuthor(); @@ -439,7 +419,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); - unsubscribeComments(); unsubscribeMetadata(); }; }, [repository]); @@ -598,10 +577,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setSearchMatchesState(matches); }, []); - const setActiveCommentId = useCallback((id: string | null) => { - setActiveCommentIdState(id); - }, []); - const setProjectTitle = useCallback( (title: string) => { setProjectTitleState(title); @@ -679,10 +654,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = currentSearchIndex, setCurrentSearchIndex, searchMatches, - setSearchMatches, - comments, - activeCommentId, - setActiveCommentId, projectTitle, setProjectTitle, projectAuthor, @@ -742,10 +713,6 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = currentSearchIndex, setCurrentSearchIndex, searchMatches, - setSearchMatches, - comments, - activeCommentId, - setActiveCommentId, projectTitle, setProjectTitle, projectAuthor, diff --git a/src/lib/editor/document-editor-config.ts b/src/lib/editor/document-editor-config.ts new file mode 100644 index 0000000..3dcc3b0 --- /dev/null +++ b/src/lib/editor/document-editor-config.ts @@ -0,0 +1,96 @@ +import * as Y from "yjs"; +import type { AnyExtension } from "@tiptap/core"; +import { ProjectState } from "@src/lib/project/project-state"; +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { TITLEPAGE_BASE_EXTENSIONS } from "@src/lib/titlepage/editor"; + +export type PaginationMode = "screenplay" | "titlepage"; + +export interface DocumentEditorFeatures { + /** Whether comments are enabled (CommentMark, CommentCards). */ + comments: boolean; + /** Whether right-click "Shelve" actions are available (always false until shelf phase). */ + shelving: boolean; + /** Character name highlights in the editor. */ + characterHighlights: boolean; + /** Search term highlights. */ + searchHighlights: boolean; + /** Scene color bookmark decorations. */ + sceneBookmarks: boolean; + /** Prevent duplicate scene-ids on paste. */ + sceneIdDedup: boolean; + /** Character / location autocomplete menus. */ + suggestions: boolean; + /** CONT'D / MORE orphan prevention. */ + orphanPrevention: boolean; + /** User-configurable keybind actions. */ + keybinds: boolean; + /** Fountain auto-format extension. */ + fountain: boolean; + /** CONT'D extension. */ + contd: boolean; + /** Which pagination header/footer style to apply. */ + paginationMode: PaginationMode; +} + +export interface DocumentEditorConfig { + /** Logical type for conditional behaviour in the hook/panel. */ + type: "screenplay" | "title"; + /** Base Tiptap extensions (defines the ProseMirror schema). */ + baseExtensions: AnyExtension[]; + /** + * Returns the Y.XmlFragment that Collaboration binds to. + * Evaluated lazily so configs can be module-level constants. + */ + getFragment: (projectState: ProjectState) => Y.XmlFragment; + /** + * Returns the Y.Map for per-document comments, or null if comments are disabled. + * Evaluated lazily for the same reason as getFragment. + */ + getCommentsMap: (projectState: ProjectState) => Y.Map | null; + features: DocumentEditorFeatures; +} + +// ---- Preset configs ---- + +export const SCREENPLAY_EDITOR_CONFIG: DocumentEditorConfig = { + type: "screenplay", + baseExtensions: BASE_EXTENSIONS, // CommentMark added dynamically by hook when features.comments=true + getFragment: (s) => s.screenplayFragment(), + getCommentsMap: (s) => s.comments(), + features: { + comments: true, + shelving: false, + characterHighlights: true, + searchHighlights: true, + sceneBookmarks: true, + sceneIdDedup: true, + suggestions: true, + orphanPrevention: true, + keybinds: true, + fountain: true, + contd: true, + paginationMode: "screenplay", + }, +}; + +export const TITLEPAGE_EDITOR_CONFIG: DocumentEditorConfig = { + type: "title", + baseExtensions: TITLEPAGE_BASE_EXTENSIONS, + getFragment: (s) => s.titlepageFragment(), + getCommentsMap: () => null, + features: { + comments: false, + shelving: false, + characterHighlights: false, + searchHighlights: false, + sceneBookmarks: false, + sceneIdDedup: false, + suggestions: false, + orphanPrevention: false, + keybinds: false, + fountain: false, + contd: false, + paginationMode: "titlepage", + }, +}; diff --git a/src/lib/editor/use-document-comments.ts b/src/lib/editor/use-document-comments.ts new file mode 100644 index 0000000..f294df5 --- /dev/null +++ b/src/lib/editor/use-document-comments.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import * as Y from "yjs"; +import { Comment, CommentReply } from "@src/lib/utils/types"; +import type { ProjectRepository } from "@src/lib/project/project-repository"; + +export interface DocumentCommentOps { + comments: Comment[]; + activeCommentId: string | null; + setActiveCommentId: (id: string | null) => void; + addComment: (partial: Omit) => string; + updateComment: (id: string, data: Partial) => void; + deleteComment: (id: string) => void; + addReply: (commentId: string, reply: Omit) => string | undefined; + resolveComment: (id: string) => void; +} + +/** + * Manages per-document comment state against a specific Y.Map. + * When commentsMap is null all state is empty and all ops are no-ops. + */ +export const useDocumentComments = ( + commentsMap: Y.Map | null | undefined, + repository: ProjectRepository | null, +): DocumentCommentOps => { + const [comments, setComments] = useState([]); + const [activeCommentId, setActiveCommentId] = useState(null); + + // Refs so CRUD callbacks do not need to be recreated on every map/repo change + const mapRef = useRef(commentsMap); + useEffect(() => { mapRef.current = commentsMap; }, [commentsMap]); + + const repoRef = useRef(repository); + useEffect(() => { repoRef.current = repository; }, [repository]); + + // Observe the Y.Map and drive local comment state + useEffect(() => { + if (!commentsMap) { + setComments([]); + return; + } + setComments(Object.values(commentsMap.toJSON() as Record)); + const observer = () => { + setComments(Object.values(commentsMap.toJSON() as Record)); + }; + commentsMap.observe(observer); + return () => commentsMap.unobserve(observer); + }, [commentsMap]); + + const addComment = useCallback((partial: Omit): string => { + const repo = repoRef.current; + const map = mapRef.current; + if (!repo || !map) return ""; + return repo.addCommentToMap(map, partial); + }, []); + + const updateComment = useCallback((id: string, data: Partial): void => { + const repo = repoRef.current; + const map = mapRef.current; + if (!repo || !map) return; + repo.updateCommentInMap(map, id, data); + }, []); + + const deleteComment = useCallback((id: string): void => { + const repo = repoRef.current; + const map = mapRef.current; + if (!repo || !map) return; + repo.deleteCommentFromMap(map, id); + }, []); + + const addReply = useCallback((commentId: string, reply: Omit): string | undefined => { + const repo = repoRef.current; + const map = mapRef.current; + if (!repo || !map) return undefined; + return repo.addReplyToMap(map, commentId, reply); + }, []); + + const resolveComment = useCallback((id: string): void => { + const repo = repoRef.current; + const map = mapRef.current; + if (!repo || !map) return; + repo.resolveCommentInMap(map, id); + }, []); + + return { + comments, + activeCommentId, + setActiveCommentId, + addComment, + updateComment, + deleteComment, + addReply, + resolveComment, + }; +}; diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts new file mode 100644 index 0000000..ba8ff0f --- /dev/null +++ b/src/lib/editor/use-document-editor.ts @@ -0,0 +1,475 @@ +"use client"; + +import { useCallback, useContext, useEffect, useRef } from "react"; +import { Editor, useEditor } from "@tiptap/react"; +import Collaboration from "@tiptap/extension-collaboration"; +import CollaborationCaret from "@tiptap/extension-collaboration-caret"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums"; +import { getRandomColor } from "@src/lib/utils/misc"; +import { useUser } from "@src/lib/utils/hooks"; +import { getStylesFromMarks, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor"; +import { ScriptioPagination } from "@src/lib/screenplay/extensions/pagination-extension"; +import { KeybindsExtension } from "@src/lib/screenplay/extensions/keybinds-extension"; +import { executeKeybindAction } from "@src/lib/utils/keybinds"; +import { + createCharacterHighlightExtension, + refreshCharacterHighlights, +} from "@src/lib/screenplay/extensions/character-highlight-extension"; +import { + createSearchHighlightExtension, + refreshSearchHighlights, + SearchMatch, +} from "@src/lib/screenplay/extensions/search-highlight-extension"; +import { + createSceneBookmarkExtension, + refreshSceneBookmarks, +} from "@src/lib/screenplay/extensions/scene-bookmark-extension"; +import { createSceneIdDedupExtension } from "@src/lib/screenplay/extensions/scene-id-dedup-extension"; +import { CommentMark } from "@src/lib/screenplay/extensions/comment-highlight-extension"; +import { + getActiveTitlePageElement, +} from "@src/lib/titlepage/editor"; +import { DocumentEditorConfig } from "./document-editor-config"; +import type { SuggestionData } from "@components/editor/SuggestionMenu"; + +export interface DocumentEditorCallbacks { + // Screenplay-type callbacks + setActiveElement?: (element: ScreenplayElement, applyStyle: boolean) => void; + setSelectedStyles?: (style: Style) => void; + updateSuggestions?: (suggestions: string[]) => void; + updateSuggestionsData?: (data: SuggestionData) => void; + /** Per-document: wired from useDocumentComments */ + setActiveCommentId?: (id: string | null) => void; + userKeybinds?: Record; + globalContext?: { toggleFocusMode: () => void; saveProject: () => void }; + // Title-type callbacks + setSelectedTitlePageElement?: (element: TitlePageElement) => void; +} + +/** + * Unified editor hook that replaces both useScriptioEditor and useTitlePageEditor. + * Builds a Tiptap editor instance bound to the Y.XmlFragment specified in config. + */ +export const useDocumentEditor = ( + config: DocumentEditorConfig, + callbacks: DocumentEditorCallbacks, +): Editor | null => { + const projectCtx = useContext(ProjectContext); + const { user } = useUser(); + const { + repository, + provider, + isYjsReady, + highlightedCharacters, + characters, + locations, + pageFormat: pageSize, + scenes, + searchTerm, + searchFilters, + currentSearchIndex, + setSearchMatches, + contdLabel, + moreLabel, + } = projectCtx; + + const projectState = repository?.getState(); + const features = config.features; + + // ---- Stable refs for callbacks and live data ---- + const charactersRef = useRef(characters); + const locationsRef = useRef(locations); + const highlightedCharactersRef = useRef>(highlightedCharacters); + const scenesRef = useRef(scenes); + const repositoryRef = useRef(repository); + const searchTermRef = useRef(searchTerm); + const searchFiltersRef = useRef>(searchFilters); + const currentSearchIndexRef = useRef(currentSearchIndex); + const setSearchMatchesRef = useRef(setSearchMatches); + const contdLabelRef = useRef(contdLabel); + const moreLabelRef = useRef(moreLabel); + const callbacksRef = useRef(callbacks); + + const userInfoRef = useRef({ + name: user?.username || "User_" + Math.floor(Math.random() * 1000), + color: user?.color || getRandomColor(), + }); + + // Keep all refs in sync + useEffect(() => { charactersRef.current = characters; }, [characters]); + useEffect(() => { locationsRef.current = locations; }, [locations]); + useEffect(() => { highlightedCharactersRef.current = highlightedCharacters; }, [highlightedCharacters]); + useEffect(() => { scenesRef.current = scenes; }, [scenes]); + useEffect(() => { repositoryRef.current = repository; }, [repository]); + useEffect(() => { searchTermRef.current = searchTerm; }, [searchTerm]); + useEffect(() => { searchFiltersRef.current = searchFilters; }, [searchFilters]); + useEffect(() => { currentSearchIndexRef.current = currentSearchIndex; }, [currentSearchIndex]); + useEffect(() => { setSearchMatchesRef.current = setSearchMatches; }, [setSearchMatches]); + useEffect(() => { contdLabelRef.current = contdLabel; }, [contdLabel]); + useEffect(() => { moreLabelRef.current = moreLabel; }, [moreLabel]); + useEffect(() => { callbacksRef.current = callbacks; }, [callbacks]); + + const lastReportedElementRef = useRef(null); + + const currentSuggestionsRef = useRef([]); + const currentSuggestionDataRef = useRef(null); + + // Debounced suggestion setters + const setSuggestions = useCallback( + (suggestions: string[]) => { + const cb = callbacksRef.current.updateSuggestions; + if (!cb) return; + const current = currentSuggestionsRef.current; + if (suggestions.length === 0 && current.length === 0) return; + if ( + suggestions.length === current.length && + suggestions.every((s, i) => s === current[i]) + ) return; + currentSuggestionsRef.current = suggestions; + cb(suggestions); + }, + [], + ); + + const setSuggestionData = useCallback( + (data: SuggestionData) => { + const cb = callbacksRef.current.updateSuggestionsData; + if (!cb) return; + const current = currentSuggestionDataRef.current; + if ( + current && + current.cursor === data.cursor && + current.cursorInNode === data.cursorInNode && + current.textOffset === data.textOffset + ) return; + currentSuggestionDataRef.current = data; + cb(data); + }, + [], + ); + + // ---- Dynamic extensions (created once, read from refs) ---- + const characterHighlightExtension = features.characterHighlights + ? createCharacterHighlightExtension({ + getHighlightedCharacters: () => highlightedCharactersRef.current, + getCharacterColor: (name: string) => { + const current = charactersRef.current; + if (!current) return undefined; + const upperName = name.toUpperCase(); + const key = Object.keys(current).find((k) => k.toUpperCase() === upperName); + return key ? current[key]?.color : undefined; + }, + }) + : null; + + const sceneBookmarkExtension = features.sceneBookmarks + ? createSceneBookmarkExtension({ + getSceneColor: (sceneId: string) => { + const current = scenesRef.current; + if (!current) return undefined; + const scene = current.find((s) => s.id === sceneId); + return scene?.color; + }, + }) + : null; + + const sceneIdDedupExtension = features.sceneIdDedup + ? createSceneIdDedupExtension({ + duplicatePersistentScene: (originalId: string, newId: string) => { + repositoryRef.current?.duplicateScene(originalId, newId); + }, + }) + : null; + + const commentMarkExtension = features.comments + ? CommentMark.configure({ + onCommentActivated: (commentId: string | null) => { + callbacksRef.current.setActiveCommentId?.(commentId); + }, + }) + : null; + + const searchHighlightExtension = features.searchHighlights + ? createSearchHighlightExtension({ + getSearchTerm: () => searchTermRef.current, + getEnabledFilters: () => searchFiltersRef.current, + getCurrentMatchIndex: () => currentSearchIndexRef.current, + onMatchesFound: (matches: SearchMatch[]) => { + setSearchMatchesRef.current(matches); + }, + }) + : null; + + // ---- Build the editor ---- + const editor = useEditor( + { + immediatelyRender: false, + extensions: [ + ...config.baseExtensions, + + // Comment mark (screenplay only, requires configured callback) + ...(commentMarkExtension ? [commentMarkExtension] : []), + + // Collaborative editing + ...(projectState && isYjsReady + ? [ + Collaboration.configure({ + document: projectState, + fragment: config.getFragment(projectState), + }), + ] + : []), + + // Collaboration carets + ...(provider && isYjsReady + ? [ + CollaborationCaret.configure({ + provider, + user: userInfoRef.current, + render: (user: any) => { + const caret = document.createElement("span"); + caret.classList.add("collab-caret"); + caret.style.borderLeft = `2px solid ${user.color}`; + const label = document.createElement("div"); + label.classList.add("collab-caret-label"); + label.style.backgroundColor = user.color; + label.innerText = user.name; + label.contentEditable = "false"; + caret.appendChild(label); + return caret; + }, + }), + ] + : []), + + // Pagination + ScriptioPagination.configure( + config.features.paginationMode === "screenplay" + ? { + pageGap: 20, + headerRight: `

{page}.

`, + customHeader: { + 1: { + headerLeft: "", + headerRight: `

`, + }, + }, + footerRight: "", + ...SCREENPLAY_FORMATS[pageSize], + } + : { + pageGap: 20, + headerLeft: "", + headerRight: "", + footerLeft: "", + footerRight: "", + customHeader: {}, + customFooter: {}, + ...SCREENPLAY_FORMATS[pageSize], + }, + ), + + // Screenplay-only extensions + ...(features.keybinds && callbacks.userKeybinds !== undefined + ? [ + KeybindsExtension.configure({ + userKeybinds: callbacks.userKeybinds || {}, + onAction: (id, editorInstance) => { + const gc = callbacksRef.current.globalContext; + if (!gc) return; + executeKeybindAction(id, { + editor: editorInstance, + toggleFocusMode: gc.toggleFocusMode, + saveProject: gc.saveProject, + }); + }, + }), + ] + : []), + + ...(characterHighlightExtension ? [characterHighlightExtension] : []), + ...(searchHighlightExtension ? [searchHighlightExtension] : []), + ...(sceneBookmarkExtension ? [sceneBookmarkExtension] : []), + ...(sceneIdDedupExtension ? [sceneIdDedupExtension] : []), + ], + + editorProps: { + handleScrollToSelection: () => true, + }, + + onSelectionUpdate({ editor, transaction }) { + const cb = callbacksRef.current; + + if (config.type === "screenplay") { + const anchor = (transaction as any).curSelection.$anchor; + const node = anchor.parent; + const elementAnchor = node.attrs.class as ScreenplayElement; + + lastReportedElementRef.current = elementAnchor; + cb.setActiveElement?.(elementAnchor, false); + if (anchor.nodeBefore) { + cb.setSelectedStyles?.(getStylesFromMarks(anchor.nodeBefore.marks)); + } + if (!transaction.docChanged) { + setSuggestions([]); + } + } else if (config.type === "title") { + const activeElement = getActiveTitlePageElement(editor); + cb.setSelectedTitlePageElement?.(activeElement); + const anchor = editor.state.selection.$anchor; + if (anchor.nodeBefore) { + cb.setSelectedStyles?.( + getStylesFromMarks(anchor.nodeBefore.marks as any[]), + ); + } else { + cb.setSelectedStyles?.(Style.None); + } + } + }, + + onUpdate({ editor, transaction }) { + if (!transaction.docChanged) return; + if (config.type !== "screenplay") return; + + const cb = callbacksRef.current; + const anchor = (transaction as any).curSelection.$anchor; + const node = anchor.parent; + const elementAnchor = node.attrs.class as ScreenplayElement; + const nodeSize: number = node.content.size; + const cursorInNode: number = anchor.parentOffset; + const cursor: number = anchor.pos; + + if (!features.suggestions) return; + + if (elementAnchor === ScreenplayElement.Character) { + const currentCharacters = charactersRef.current; + if (!currentCharacters || nodeSize === 0) { setSuggestions([]); return; } + if (cursorInNode !== nodeSize) { setSuggestions([]); return; } + + const text = node.textContent; + const trimmed = text.slice(0, cursorInNode).toUpperCase().trim(); + const currentCleanName = trimmed.replace(/\s*\(.*?\)\s*$/, "").trim(); + const suggestions = Object.keys(currentCharacters) + .filter((name) => { + const upperName = name.toUpperCase(); + return ( + upperName !== currentCleanName && + upperName.startsWith(trimmed) && + upperName !== text.toUpperCase().trim() + ); + }) + .slice(0, 10); + + if (suggestions.length > 0) { + const pagePos = editor.view.coordsAtPos(cursor); + setSuggestionData({ + position: { x: pagePos.left, y: pagePos.top }, + cursor, + cursorInNode, + nodeType: "character", + }); + } + setSuggestions(suggestions); + } else if (elementAnchor === ScreenplayElement.Scene) { + const currentLocations = locationsRef.current; + if (!currentLocations) { setSuggestions([]); return; } + if (cursorInNode !== nodeSize) { setSuggestions([]); return; } + + const text = node.textContent.toUpperCase(); + const prefixMatch = text.match(/^(INT\. |EXT\. |INT\/EXT\. |I\/E\. )\s*/i); + if (!prefixMatch) { setSuggestions([]); return; } + + const prefixLength = prefixMatch[0].length; + const afterPrefix = text.slice(prefixLength); + let suggestions = Object.keys(currentLocations); + if (afterPrefix.length > 0) { + const cleanAfterPrefix = afterPrefix.trim(); + suggestions = suggestions + .filter((location) => { + const upperLocation = location.toUpperCase(); + return upperLocation.startsWith(afterPrefix) && upperLocation !== cleanAfterPrefix; + }) + .slice(0, 10); + } else { + suggestions = suggestions.slice(0, 10); + } + + if (suggestions.length > 0) { + const pagePos = editor.view.coordsAtPos(cursor, -1); + setSuggestionData({ + position: { x: pagePos.left, y: pagePos.top }, + cursor, + cursorInNode, + textOffset: prefixLength, + nodeType: "scene", + }); + } + setSuggestions(suggestions); + } else { + setSuggestions([]); + } + }, + + onTransaction({ editor, transaction }) { + if (config.type !== "screenplay") return; + const cb = callbacksRef.current; + const { $from } = editor.state.selection; + const currentElement = $from.parent.attrs.class as ScreenplayElement; + if (currentElement !== lastReportedElementRef.current) { + lastReportedElementRef.current = currentElement; + cb.setActiveElement?.(currentElement, false); + } + }, + }, + // Rebuild the editor when Yjs readiness or the fragment changes + [projectState, provider, isYjsReady], + ); + + // ---- Post-mount effects ---- + + // Sync collaboration caret user info + useEffect(() => { + userInfoRef.current = { + name: user?.username || userInfoRef.current.name, + color: user?.color || userInfoRef.current.color, + }; + if (provider) { + provider.awareness.setLocalStateField("user", userInfoRef.current); + } + }, [user?.username, user?.color, provider]); + + // Refresh character highlights + useEffect(() => { + if (editor && features.characterHighlights) { + refreshCharacterHighlights(editor); + } + }, [editor, highlightedCharacters, characters, features.characterHighlights]); + + // Refresh scene bookmarks + useEffect(() => { + if (editor && features.sceneBookmarks) { + refreshSceneBookmarks(editor); + } + }, [editor, scenes, features.sceneBookmarks]); + + // Refresh search highlights + useEffect(() => { + if (editor && features.searchHighlights) { + refreshSearchHighlights(editor); + } + }, [editor, searchTerm, searchFilters, currentSearchIndex, features.searchHighlights]); + + // Sync page size when pageFormat changes + useEffect(() => { + if (!editor || editor.isDestroyed || !editor.view) return; + try { + editor.commands.updatePageSize(SCREENPLAY_FORMATS[pageSize]); + } catch { + // Editor view not mounted yet + } + }, [editor, pageSize]); + + return editor; +}; diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 9788285..9a5d089 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -1,4 +1,5 @@ import { v4 as uuidv4 } from "uuid"; +import * as Y from "yjs"; import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; import { ScreenplaySchema } from "../screenplay/editor"; import { Comment, CommentReply, Screenplay } from "../utils/types"; @@ -312,34 +313,36 @@ export class ProjectRepository { // COMMENTS // // -------------------------------- // - get comments(): Record { - return this.ydoc.comments().toJSON() as Record; + /** + * Generic comment operations — work on any Y.Map keyed by comment UUID. + * Use the convenience wrappers below for the main screenplay comments. + */ + + getCommentsFromMap(map: Y.Map): Record { + return map.toJSON() as Record; } - getComment(commentId: string): Comment | undefined { - return this.ydoc.comments().get(commentId) as Comment | undefined; + getCommentFromMap(map: Y.Map, commentId: string): Comment | undefined { + return map.get(commentId) as Comment | undefined; } - addComment(comment: Omit): string { + addCommentToMap(map: Y.Map, comment: Omit): string { const id = uuidv4(); - const map = this.ydoc.comments(); map.set(id, { ...comment, id }); return id; } - updateComment(commentId: string, data: Partial): void { - const map = this.ydoc.comments(); + updateCommentInMap(map: Y.Map, commentId: string, data: Partial): void { const existing = map.get(commentId) as Comment | undefined; if (!existing) return; map.set(commentId, { ...existing, ...data }); } - resolveComment(commentId: string): void { - this.updateComment(commentId, { resolved: true }); + resolveCommentInMap(map: Y.Map, commentId: string): void { + this.updateCommentInMap(map, commentId, { resolved: true }); } - addReply(commentId: string, reply: Omit): string | undefined { - const map = this.ydoc.comments(); + addReplyToMap(map: Y.Map, commentId: string, reply: Omit): string | undefined { const existing = map.get(commentId) as Comment | undefined; if (!existing) return undefined; const id = uuidv4(); @@ -348,19 +351,51 @@ export class ProjectRepository { return id; } - deleteComment(commentId: string): void { - const map = this.ydoc.comments(); + deleteCommentFromMap(map: Y.Map, commentId: string): void { if (map.has(commentId)) { map.delete(commentId); } } - observeComments(callback: (comments: Record) => void): () => void { - const map = this.ydoc.comments(); + observeCommentsMap(map: Y.Map, callback: (comments: Record) => void): () => void { const observer = () => callback(map.toJSON() as Record); map.observe(observer); return () => map.unobserve(observer); } + + // ---- Screenplay comment convenience wrappers ---- + + get comments(): Record { + return this.getCommentsFromMap(this.ydoc.comments()); + } + + getComment(commentId: string): Comment | undefined { + return this.getCommentFromMap(this.ydoc.comments(), commentId); + } + + addComment(comment: Omit): string { + return this.addCommentToMap(this.ydoc.comments(), comment); + } + + updateComment(commentId: string, data: Partial): void { + this.updateCommentInMap(this.ydoc.comments(), commentId, data); + } + + resolveComment(commentId: string): void { + this.resolveCommentInMap(this.ydoc.comments(), commentId); + } + + addReply(commentId: string, reply: Omit): string | undefined { + return this.addReplyToMap(this.ydoc.comments(), commentId, reply); + } + + deleteComment(commentId: string): void { + this.deleteCommentFromMap(this.ydoc.comments(), commentId); + } + + observeComments(callback: (comments: Record) => void): () => void { + return this.observeCommentsMap(this.ydoc.comments(), callback); + } } /** diff --git a/src/lib/screenplay/editor.ts b/src/lib/screenplay/editor.ts index 4f2539f..dedc2b9 100644 --- a/src/lib/screenplay/editor.ts +++ b/src/lib/screenplay/editor.ts @@ -1,36 +1,13 @@ -"use client"; - -import { Editor, getSchema, JSONContent, useEditor } from "@tiptap/react"; +import { Editor, getSchema, JSONContent } from "@tiptap/react"; import { ScreenplayElement, Style, TitlePageElement } from "../utils/enums"; -import { ProjectContext } from "@src/context/ProjectContext"; import Document from "@tiptap/extension-document"; import Text from "@tiptap/extension-text"; -import Collaboration from "@tiptap/extension-collaboration"; -import CollaborationCaret from "@tiptap/extension-collaboration-caret"; -import { useCallback, useContext, useEffect, useRef } from "react"; -import { SuggestionData } from "@components/editor/SuggestionMenu"; -import { useUser } from "../utils/hooks"; -import { getRandomColor } from "../utils/misc"; -import { ProjectMembershipPayload } from "@src/server/repository/project-repository"; import { ScreenplayNodes, ScriptioBold, ScriptioItalic, ScriptioUnderline } from "@src/lib/screenplay/nodes"; import { Placeholder } from "./extensions/placeholder-extension"; -import { ScriptioPagination, PAGE_SIZES } from "./extensions/pagination-extension"; -import { KeybindsExtension } from "./extensions/keybinds-extension"; -import { executeKeybindAction } from "../utils/keybinds"; +import { PAGE_SIZES } from "./extensions/pagination-extension"; import { ContdExtension } from "./extensions/contd-extension"; -import { - createCharacterHighlightExtension, - refreshCharacterHighlights, -} from "./extensions/character-highlight-extension"; -import { - createSearchHighlightExtension, - refreshSearchHighlights, - SearchMatch, -} from "./extensions/search-highlight-extension"; -import { createSceneBookmarkExtension, refreshSceneBookmarks } from "./extensions/scene-bookmark-extension"; -import { createSceneIdDedupExtension } from "./extensions/scene-id-dedup-extension"; import { CommentMark } from "./extensions/comment-highlight-extension"; import { FountainExtension } from "./extensions/fountain-extension"; @@ -182,463 +159,3 @@ export const BASE_EXTENSIONS = [ ]; export const ScreenplaySchema = getSchema([...BASE_EXTENSIONS, CommentMark]); - -export const useScriptioEditor = ( - project: ProjectMembershipPayload["project"] | undefined, - setActiveElement: (element: ScreenplayElement, applyStyle: boolean) => void, - setSelectedStyles: (style: Style) => void, - updateSuggestions: (suggestions: string[]) => void, - updateSuggestionsData: (data: SuggestionData) => void, - userKeybinds: Record | undefined, - globalContext: { toggleFocusMode: () => void; saveProject: () => void }, -) => { - const projectCtx = useContext(ProjectContext); - const { user } = useUser(); - const { - repository, - provider, - isYjsReady, - highlightedCharacters, - characters, - locations, - pageFormat: pageSize, - scenes, - searchTerm, - searchFilters, - currentSearchIndex, - setSearchMatches, - setActiveCommentId, - contdLabel, - moreLabel, - } = projectCtx; - - // Refs for autocomplete data - const charactersRef = useRef(characters); - const locationsRef = useRef(locations); - const projectState = repository?.getState(); - - // Ref to track current suggestions and avoid unnecessary state updates - const currentSuggestionsRef = useRef([]); - const currentSuggestionDataRef = useRef(null); - - const setSuggestionData = useCallback( - (data: SuggestionData) => { - // Skip update if data hasn't meaningfully changed - const current = currentSuggestionDataRef.current; - if ( - current && - current.cursor === data.cursor && - current.cursorInNode === data.cursorInNode && - current.textOffset === data.textOffset - ) { - return; - } - currentSuggestionDataRef.current = data; - updateSuggestionsData(data); - }, - [updateSuggestionsData], - ); - - const setSuggestions = useCallback( - (suggestions: string[]) => { - // Skip update if suggestions haven't changed (both empty or same content) - const current = currentSuggestionsRef.current; - if (suggestions.length === 0 && current.length === 0) { - return; - } - if (suggestions.length === current.length && suggestions.every((s, i) => s === current[i])) { - return; - } - currentSuggestionsRef.current = suggestions; - updateSuggestions(suggestions); - }, - [updateSuggestions], - ); - - const userInfoRef = useRef({ - name: user?.username || "User_" + Math.floor(Math.random() * 1000), - color: user?.color || getRandomColor(), - }); - - // Ref to track the last reported active element, so onTransaction can detect stale values - const lastReportedElementRef = useRef(null); - - // Refs for character highlighting - these are read by the extension plugin - const highlightedCharactersRef = useRef>(highlightedCharacters); - const charactersDataRef = useRef(characters); - - // Ref for scene bookmarks - const scenesRef = useRef(scenes); - - // Ref for repository (used by scene-id dedup extension) - const repositoryRef = useRef(repository); - - // Refs for search highlighting - these are read by the search extension plugin - const searchTermRef = useRef(searchTerm); - const searchFiltersRef = useRef>(searchFilters); - const currentSearchIndexRef = useRef(currentSearchIndex); - const setSearchMatchesRef = useRef(setSearchMatches); - - // Ref for contd label - const contdLabelRef = useRef(contdLabel); - const moreLabelRef = useRef(moreLabel); - - // Keep refs in sync with state - useEffect(() => { - highlightedCharactersRef.current = highlightedCharacters; - }, [highlightedCharacters]); - - useEffect(() => { - charactersDataRef.current = characters; - }, [characters]); - - useEffect(() => { - charactersRef.current = characters; - }, [characters]); - - useEffect(() => { - locationsRef.current = locations; - }, [locations]); - - useEffect(() => { - scenesRef.current = scenes; - }, [scenes]); - - useEffect(() => { - repositoryRef.current = repository; - }, [repository]); - - useEffect(() => { - searchTermRef.current = searchTerm; - }, [searchTerm]); - - useEffect(() => { - searchFiltersRef.current = searchFilters; - }, [searchFilters]); - - useEffect(() => { - currentSearchIndexRef.current = currentSearchIndex; - }, [currentSearchIndex]); - - useEffect(() => { - setSearchMatchesRef.current = setSearchMatches; - }, [setSearchMatches]); - - useEffect(() => { - contdLabelRef.current = contdLabel; - }, [contdLabel]); - - useEffect(() => { - moreLabelRef.current = moreLabel; - }, [moreLabel]); - - useEffect(() => { - userInfoRef.current = { - name: user?.username || userInfoRef.current.name, - color: user?.color || userInfoRef.current.color, - }; - if (provider) { - provider.awareness.setLocalStateField("user", userInfoRef.current); - } - }, [user?.username, user?.color, provider]); - - // Create the character highlight extension with callback functions that read from refs - const characterHighlightExtension = createCharacterHighlightExtension({ - getHighlightedCharacters: () => highlightedCharactersRef.current, - getCharacterColor: (name: string) => { - const current = charactersDataRef.current; - if (!current) return undefined; - const upperName = name.toUpperCase(); - const key = Object.keys(current).find((k) => k.toUpperCase() === upperName); - return key ? current[key]?.color : undefined; - }, - }); - - // Create the scene bookmark extension with callback that reads from ref - const sceneBookmarkExtension = createSceneBookmarkExtension({ - getSceneColor: (sceneId: string) => { - const current = scenesRef.current; - if (!current) return undefined; - const scene = current.find((s) => s.id === sceneId); - return scene?.color; - }, - }); - - // Create the scene-id dedup extension to handle paste of persistent scenes - const sceneIdDedupExtension = createSceneIdDedupExtension({ - duplicatePersistentScene: (originalId: string, newId: string) => { - repositoryRef.current?.duplicateScene(originalId, newId); - }, - }); - - // Create the comment mark extension - const commentMarkExtension = CommentMark.configure({ - onCommentActivated: (commentId: string | null) => { - setActiveCommentId(commentId); - }, - }); - - // Create the search highlight extension with callback functions that read from refs - const searchHighlightExtension = createSearchHighlightExtension({ - getSearchTerm: () => searchTermRef.current, - getEnabledFilters: () => searchFiltersRef.current, - getCurrentMatchIndex: () => currentSearchIndexRef.current, - onMatchesFound: (matches: SearchMatch[]) => { - setSearchMatchesRef.current(matches); - }, - }); - - const scriptioEditor = useEditor( - { - immediatelyRender: false, - extensions: [ - ...BASE_EXTENSIONS, - ...(projectState && isYjsReady - ? [ - Collaboration.configure({ - document: projectState, - fragment: projectState.screenplayFragment(), - }), - ] - : []), - ...(provider && isYjsReady - ? [ - CollaborationCaret.configure({ - provider: provider, - user: userInfoRef.current, - render: (user: any) => { - const caret = document.createElement("span"); - caret.classList.add("collab-caret"); - caret.style.borderLeft = `2px solid ${user.color}`; - const label = document.createElement("div"); - label.classList.add("collab-caret-label"); - label.style.backgroundColor = user.color; - label.innerText = user.name; - label.contentEditable = "false"; - caret.appendChild(label); - return caret; - }, - }), - ] - : []), - ScriptioPagination.configure({ - pageGap: 20, - headerRight: `

{page}.

`, - customHeader: { - 1: { - // Overwrite first page header with empty header - headerLeft: "", - headerRight: `

`, - }, - }, - footerRight: "", - ...SCREENPLAY_FORMATS[pageSize], - }), - KeybindsExtension.configure({ - userKeybinds: userKeybinds || {}, - onAction: (id, editorInstance) => { - executeKeybindAction(id, { - editor: editorInstance, - toggleFocusMode: globalContext.toggleFocusMode, - saveProject: globalContext.saveProject, - }); - }, - }), - characterHighlightExtension, - searchHighlightExtension, - sceneBookmarkExtension, - sceneIdDedupExtension, - commentMarkExtension, - ], - - onSelectionUpdate({ editor, transaction }) { - const anchor = (transaction as any).curSelection.$anchor; - const node = anchor.parent; - const elementAnchor = node.attrs.class as ScreenplayElement; - - lastReportedElementRef.current = elementAnchor; - setActiveElement(elementAnchor, false); - if (anchor.nodeBefore) setSelectedStyles(getStylesFromMarks(anchor.nodeBefore.marks)); - - // Clear suggestions when moving cursor (not typing) - // onUpdate will handle showing suggestions when typing - if (!transaction.docChanged) { - setSuggestions([]); - } - }, - - onUpdate({ editor, transaction }) { - // Only show autocomplete when document content changes (typing) - if (!transaction.docChanged) return; - - const anchor = (transaction as any).curSelection.$anchor; - const node = anchor.parent; - const elementAnchor = node.attrs.class as ScreenplayElement; - - const nodeSize: number = node.content.size; - const cursorInNode: number = anchor.parentOffset; - const cursor: number = anchor.pos; - - // Character autocomplete - if (elementAnchor === ScreenplayElement.Character) { - const currentCharacters = charactersRef.current; - - // Skip if no characters or node is empty - if (!currentCharacters || nodeSize === 0) { - setSuggestions([]); - return; - } - - // Only show suggestions when cursor is at the end of the text - if (cursorInNode !== nodeSize) { - setSuggestions([]); - return; - } - - const text = node.textContent; - const trimmed: string = text.slice(0, cursorInNode).toUpperCase().trim(); - // Clean the current text the same way getCharacterNames does, - // so we can exclude the currently-typed name from suggestions - const currentCleanName = trimmed.replace(/\s*\(.*?\)\s*$/, "").trim(); - const suggestions = Object.keys(currentCharacters) - .filter((name) => { - const upperName = name.toUpperCase(); - return ( - upperName !== currentCleanName && - upperName.startsWith(trimmed) && - upperName !== text.toUpperCase().trim() - ); - }) - .slice(0, 10); - - if (suggestions.length > 0) { - const pagePos = editor.view.coordsAtPos(cursor); - setSuggestionData({ - position: { x: pagePos.left, y: pagePos.top }, - cursor, - cursorInNode, - nodeType: "character", - }); - } - setSuggestions(suggestions); - } else if (elementAnchor === ScreenplayElement.Scene) { - // Scene/Location autocomplete - const currentLocations = locationsRef.current; - if (!currentLocations) { - setSuggestions([]); - return; - } - - // Only show suggestions when cursor is at the end - if (cursorInNode !== nodeSize) { - setSuggestions([]); - return; - } - - const text = node.textContent.toUpperCase(); - - // Check if we're after a prefix like "INT. " or "EXT. " - const prefixMatch = text.match(/^(INT\. |EXT\. |INT\/EXT\. |I\/E\. )\s*/i); - if (!prefixMatch) { - setSuggestions([]); - return; - } - - const prefixLength = prefixMatch[0].length; - const afterPrefix = text.slice(prefixLength); - let suggestions = Object.keys(currentLocations); - - if (afterPrefix.length > 0) { - const cleanAfterPrefix = afterPrefix.trim(); - suggestions = suggestions - .filter((location) => { - const upperLocation = location.toUpperCase(); - return upperLocation.startsWith(afterPrefix) && upperLocation !== cleanAfterPrefix; - }) - .slice(0, 10); - } else { - suggestions = suggestions.slice(0, 10); - } - - if (suggestions.length > 0) { - // Use side=-1 to get coordinates at the text cursor position, - // not after any widget decorations (scene number) at the same pos - const pagePos = editor.view.coordsAtPos(cursor, -1); - setSuggestionData({ - position: { x: pagePos.left, y: pagePos.top }, - cursor, - cursorInNode, - textOffset: prefixLength, - nodeType: "scene", - }); - } - setSuggestions(suggestions); - } else { - setSuggestions([]); - } - }, - - onTransaction({ editor, transaction }) { - // Catch element type changes from appendTransaction (e.g. Fountain extension - // transforming an Action into a Scene Heading) that onSelectionUpdate misses, - // since onSelectionUpdate fires before appendTransaction processes. - const { $from } = editor.state.selection; - const currentElement = $from.parent.attrs.class as ScreenplayElement; - if (currentElement !== lastReportedElementRef.current) { - lastReportedElementRef.current = currentElement; - setActiveElement(currentElement, false); - } - }, - }, - [projectState, provider, isYjsReady], - ); - - useEffect(() => { - if (scriptioEditor) { - projectCtx.updateEditor(scriptioEditor); - } - return () => { - projectCtx.updateEditor(null); - }; - }, [scriptioEditor]); - - // Refresh character highlights when highlighted characters or character colors change - useEffect(() => { - if (scriptioEditor) { - refreshCharacterHighlights(scriptioEditor); - } - }, [scriptioEditor, highlightedCharacters, characters]); - - // Refresh scene bookmarks when scenes change - useEffect(() => { - if (scriptioEditor) { - refreshSceneBookmarks(scriptioEditor); - } - }, [scriptioEditor, scenes]); - - // Refresh search highlights when search state changes - useEffect(() => { - if (scriptioEditor) { - refreshSearchHighlights(scriptioEditor); - } - }, [scriptioEditor, searchTerm, searchFilters, currentSearchIndex]); - - // Sync editor page size when pageFormat changes (e.g., from another collaborator) - useEffect(() => { - if (!scriptioEditor || scriptioEditor.isDestroyed || !scriptioEditor.view) return; - try { - const format = SCREENPLAY_FORMATS[pageSize]; - scriptioEditor.commands.updatePageSize(format); - } catch { - // Editor view not mounted yet — will apply on next render - } - }, [scriptioEditor, pageSize]); - - // Force orphan prevention element update when labels change - /*useEffect(() => { - if (!scriptioEditor || scriptioEditor.isDestroyed || !scriptioEditor.view) return; - scriptioEditor.commands.forceOrphanUpdate(); - }, [scriptioEditor, contdLabel, moreLabel]);*/ - - return scriptioEditor; -}; diff --git a/src/lib/titlepage/editor.ts b/src/lib/titlepage/editor.ts index c32fca2..8ca8778 100644 --- a/src/lib/titlepage/editor.ts +++ b/src/lib/titlepage/editor.ts @@ -1,24 +1,12 @@ -"use client"; - -import { Editor, Extension, getSchema, useEditor } from "@tiptap/react"; +import { Editor, Extension, getSchema } from "@tiptap/react"; import { TitlePageElement, Style } from "../utils/enums"; -import { ProjectContext } from "@src/context/ProjectContext"; import Document from "@tiptap/extension-document"; import Text from "@tiptap/extension-text"; -import Collaboration from "@tiptap/extension-collaboration"; -import CollaborationCaret from "@tiptap/extension-collaboration-caret"; -import { useContext, useEffect, useRef } from "react"; -import { useUser } from "../utils/hooks"; -import { getRandomColor } from "../utils/misc"; import { TitlePageExtensions } from "./nodes"; import { ScriptioBold, ScriptioItalic, ScriptioUnderline } from "../screenplay/nodes"; import { Placeholder } from "../screenplay/extensions/placeholder-extension"; -import { getStylesFromMarks, SCREENPLAY_FORMATS } from "../screenplay/editor"; -import { ScriptioPagination } from "../screenplay/extensions/pagination-extension"; - -import { titlePageMetadataRef } from "./metadata-ref"; const TitlePageMetadata = Extension.create({ name: "titlePageMetadata", @@ -198,187 +186,3 @@ export const DEFAULT_TITLEPAGE_CONTENT = [ EMPTY(), LINE("left", [FORMAT_NODE(TitlePageElement.Date)]), ]; - -export const useTitlePageEditor = () => { - const projectCtx = useContext(ProjectContext); - const { user } = useUser(); - const { - repository, - provider, - isYjsReady, - setSelectedTitlePageElement, - setSelectedStyles, - pageFormat: pageSize, - projectTitle, - projectAuthor, - } = projectCtx; - - const projectState = repository?.getState(); - - // Keep the module-level ref in sync on every render so that format - // node views always resolve the latest values, even when created - // asynchronously by the Collaboration extension. - titlePageMetadataRef.projectTitle = projectTitle || ""; - titlePageMetadataRef.projectAuthor = projectAuthor || ""; - - const userInfoRef = useRef({ - name: user?.username || "User_" + Math.floor(Math.random() * 1000), - color: user?.color || getRandomColor(), - }); - - useEffect(() => { - userInfoRef.current = { - name: user?.username || userInfoRef.current.name, - color: user?.color || userInfoRef.current.color, - }; - }, [user?.username, user?.color]); - - const titlePageEditor = useEditor( - { - immediatelyRender: false, - editorProps: { - handleScrollToSelection: () => true, - }, - extensions: [ - ...TITLEPAGE_BASE_EXTENSIONS, - ...(projectState && isYjsReady - ? [ - Collaboration.configure({ - document: projectState, - fragment: projectState.titlepageFragment(), - }), - ] - : []), - ...(provider && isYjsReady - ? [ - CollaborationCaret.configure({ - provider: provider, - user: userInfoRef.current, - render: (user: any) => { - const caret = document.createElement("span"); - caret.classList.add("collab-caret"); - caret.style.borderLeft = `2px solid ${user.color}`; - const label = document.createElement("div"); - label.classList.add("collab-caret-label"); - label.style.backgroundColor = user.color; - label.innerText = user.name; - label.contentEditable = "false"; - caret.appendChild(label); - return caret; - }, - }), - ] - : []), - ScriptioPagination.configure({ - pageGap: 20, - headerLeft: "", - headerRight: "", - footerLeft: "", - footerRight: "", - customHeader: {}, - customFooter: {}, - ...SCREENPLAY_FORMATS[pageSize], - }), - ], - - onSelectionUpdate({ editor }) { - const activeElement = getActiveTitlePageElement(editor); - setSelectedTitlePageElement(activeElement); - - const anchor = editor.state.selection.$anchor; - if (anchor.nodeBefore) { - setSelectedStyles(getStylesFromMarks(anchor.nodeBefore.marks as any[])); - } else { - setSelectedStyles(Style.None); - } - }, - }, - [projectState, provider, isYjsReady], - ); - - // Update storage synchronously during render. This ensures that when TipTap - // delays nodeView mount due to immediatelyRender: false, the data is already available. - if (titlePageEditor && typeof titlePageEditor.storage === "object") { - const storage = (titlePageEditor.storage as any).titlePageMetadata; - if (storage) { - storage.projectTitle = projectTitle || ""; - storage.projectAuthor = projectAuthor || ""; - } - } - - // Register editor in ProjectContext - useEffect(() => { - if (titlePageEditor) { - projectCtx.updateTitlePageEditor(titlePageEditor); - } - return () => { - projectCtx.updateTitlePageEditor(null); - }; - }, [titlePageEditor]); - - // Initialize default template if title page is empty - useEffect(() => { - if (!titlePageEditor || !isYjsReady || !repository || !titlePageEditor.view) return; - - const state = repository.getState(); - const meta = state.metadata(); - - if (!meta.get("titlepageInitialized")) { - // Apply content first, then set the flag — if setContent throws, the flag - // stays unset so the next render will retry rather than leaving a blank page. - titlePageEditor.commands.setContent(DEFAULT_TITLEPAGE_CONTENT); - - // Apply underline to the title format node as a separate transaction — - // the same operation the navbar button performs. Marks on inline atom nodes - // are not preserved by the Collaboration extension's Yjs conversion when - // embedded in the setContent JSON, so we apply them explicitly here. - const { state, view } = titlePageEditor; - const tr = state.tr; - let modified = false; - - tr.doc.descendants((node, pos) => { - if (node.type.name === TitlePageElement.Title) { - const markType = state.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); - } - }, [titlePageEditor, isYjsReady, repository]); - - // Sync project metadata into editor storage for node view rendering - useEffect(() => { - if (!titlePageEditor || titlePageEditor.isDestroyed) return; - const storage = (titlePageEditor.storage as any).titlePageMetadata; - if (storage) { - storage.projectTitle = projectTitle || ""; - storage.projectAuthor = projectAuthor || ""; - // Refresh all format node views with updated values - storage.nodeViewUpdaters?.forEach((fn: () => void) => fn()); - if (titlePageEditor.view && !titlePageEditor.view.isDestroyed) { - titlePageEditor.view.dispatch(titlePageEditor.state.tr.setMeta("titlePageMetadataUpdate", true)); - } - } - }, [titlePageEditor, projectTitle, projectAuthor]); - - // Sync page size when format changes - useEffect(() => { - if (!titlePageEditor || titlePageEditor.isDestroyed) return; - try { - titlePageEditor.chain().updatePageSize(SCREENPLAY_FORMATS[pageSize]).run(); - } catch { - // Editor view not mounted yet - } - }, [titlePageEditor, pageSize]); - - return titlePageEditor; -}; From eef62e9d3fa1f7b902de1f8a30ee7847eab63f29 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Sat, 14 Mar 2026 01:15:34 +0100 Subject: [PATCH 3/7] refactoring editor hooks, fixed board card resize --- components/board/BoardCanvas.tsx | 1 + components/board/BoardCard.tsx | 1 + .../dashboard/project/ExportProject.tsx | 28 ++--- components/projects/ProjectPageContainer.tsx | 1 + src/context/ProjectContext.tsx | 2 + src/lib/adapters/screenplay-adapter.ts | 78 +++++++++++- src/lib/adapters/scriptio/scriptio-adapter.ts | 67 +++++++++- src/lib/import/import-project.ts | 115 +++++++++++++----- src/lib/project/project-state.ts | 7 +- 9 files changed, 242 insertions(+), 58 deletions(-) diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index f707bef..647fdf7 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -460,6 +460,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..0844981 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -129,6 +129,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/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/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/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 22aef22..4ae159a 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -654,6 +654,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = currentSearchIndex, setCurrentSearchIndex, searchMatches, + setSearchMatches, projectTitle, setProjectTitle, projectAuthor, @@ -713,6 +714,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = currentSearchIndex, setCurrentSearchIndex, searchMatches, + setSearchMatches, projectTitle, setProjectTitle, projectAuthor, diff --git a/src/lib/adapters/screenplay-adapter.ts b/src/lib/adapters/screenplay-adapter.ts index af94850..62b28b7 100644 --- a/src/lib/adapters/screenplay-adapter.ts +++ b/src/lib/adapters/screenplay-adapter.ts @@ -3,6 +3,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { replaceScreenplay } from "../screenplay/editor"; import { Editor } from "@tiptap/react"; import { ProjectData, ProjectState } from "../project/project-state"; +import { ProjectRepository } from "../project/project-repository"; export type BaseExportOptions = { title: string; @@ -53,11 +54,84 @@ export abstract class ProjectAdapter { + if (project.metadata) { + const metadataMap = ydoc.metadata(); + Object.entries(project.metadata).forEach(([key, value]) => { + metadataMap.set(key, value); + }); + } + + if (project.characters) { + const charactersMap = ydoc.characters(); + charactersMap.clear(); + Object.entries(project.characters).forEach(([key, value]) => { + charactersMap.set(key, value); + }); + } + + if (project.locations) { + const locationsMap = ydoc.locations(); + locationsMap.clear(); + Object.entries(project.locations).forEach(([key, value]) => { + locationsMap.set(key, value); + }); + } + + if (project.scenes) { + const scenesMap = ydoc.scenes(); + scenesMap.clear(); + Object.entries(project.scenes).forEach(([key, value]) => { + scenesMap.set(key, value); + }); + } + + if (project.board) { + const boardMap = ydoc.board(); + boardMap.clear(); + Object.entries(project.board).forEach(([key, value]) => { + boardMap.set(key, value); + }); + } + + if (project.layout) { + const layoutMap = ydoc.layout(); + Object.entries(project.layout).forEach(([key, value]) => { + layoutMap.set(key, value); + }); + } + + if (project.comments) { + const commentsMap = ydoc.comments(); + commentsMap.clear(); + Object.entries(project.comments).forEach(([key, value]) => { + commentsMap.set(key, value); + }); + } + }); + } } catch (error) { console.error(`Failed to import from ${this.label}`, error); throw new Error("Import failed or file is corrupt"); diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index db77129..17bc822 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,5 +1,8 @@ import { LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { replaceScreenplay } from "../../screenplay/editor"; +import { Editor } from "@tiptap/react"; +import { ProjectRepository } from "../../project/project-repository"; import * as fflate from "fflate"; import * as Y from "yjs"; @@ -69,13 +72,14 @@ export class ScriptioAdapter extends ProjectAdapter { // with any text editor. const data: ProjectData = { screenplay: project.screenplay(), + titlepage: project.titlepage(), metadata: project.metadata().toJSON() as ProjectMetadata, characters: project.characters().toJSON(), scenes: project.scenes().toJSON(), - cards: project.cards().toJSON(), locations: project.locations().toJSON(), board: project.board().toJSON(), layout: project.layout().toJSON() as LayoutData, + comments: project.comments().toJSON(), }; payload = new TextEncoder().encode(JSON.stringify(data, null, 2)); } else { @@ -118,13 +122,14 @@ export class ScriptioAdapter extends ProjectAdapter { return { screenplay: tmpDoc.screenplay(), + titlepage: tmpDoc.titlepage(), metadata: tmpDoc.metadata().toJSON() as ProjectMetadata, characters: tmpDoc.characters().toJSON(), scenes: tmpDoc.scenes().toJSON(), - cards: tmpDoc.cards().toJSON(), locations: tmpDoc.locations().toJSON(), board: tmpDoc.board().toJSON(), layout: tmpDoc.layout().toJSON() as LayoutData, + comments: tmpDoc.comments().toJSON(), }; } catch (error) { console.error("Failed to parse .scriptio file", error); @@ -133,4 +138,62 @@ export class ScriptioAdapter extends ProjectAdapter { tmpDoc.destroy(); } } + + public import( + rawContent: ArrayBuffer, + editor?: Editor | null, + titlePageEditor?: Editor | null, + repository?: ProjectRepository | null, + ): void { + const data = new Uint8Array(rawContent); + const { flags, payloadOffset } = parseHeader(data); + const payload = data.subarray(payloadOffset); + const isReadable = (flags & FLAG_READABLE_JSON) !== 0; + + if (!isReadable && repository) { + const ydoc = repository.getState() as ProjectState; + try { + const decompressed = fflate.unzlibSync(payload); + + // To truly "replace" the state, we clear existing content + // to avoid merging with the previous project data. + ydoc.transact(() => { + // Fragments - delete all content + const screenplay = ydoc.screenplayFragment(); + if (screenplay.length > 0) screenplay.delete(0, screenplay.length); + const titlepage = ydoc.titlepageFragment(); + if (titlepage.length > 0) titlepage.delete(0, titlepage.length); + + // Maps - clear all entries + ydoc.metadata().clear(); + ydoc.characters().clear(); + ydoc.scenes().clear(); + ydoc.locations().clear(); + ydoc.cards().clear(); + ydoc.board().clear(); + ydoc.layout().clear(); + ydoc.comments().clear(); + }); + + // Apply the new state + Y.applyUpdate(ydoc, decompressed); + + // Refresh editors if provided + const projectData = this.convertFrom(rawContent); + if (editor && projectData.screenplay) { + replaceScreenplay(editor, projectData.screenplay); + } + if (titlePageEditor && projectData.titlepage) { + replaceScreenplay(titlePageEditor, projectData.titlepage); + } + + return; + } catch (error) { + console.warn("Failed to apply binary Scriptio update directly, falling back to base import.", error); + } + } + + // Fallback to the base implementation for readable JSON or if repository is missing + super.import(rawContent, editor, titlePageEditor, repository); + } } diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts index d66ed43..f31e51f 100644 --- a/src/lib/import/import-project.ts +++ b/src/lib/import/import-project.ts @@ -3,18 +3,20 @@ * Creates remote projects for logged-in users, local projects for offline/desktop. */ -import { ProjectState } from "@src/lib/project/project-state"; +import { ProjectData, ProjectState } from "@src/lib/project/project-state"; import { getAdapterByFilename } from "@src/lib/adapters/registry"; import { createLocalProject, createLocalProjectWithId } from "@src/lib/persistence/local-projects"; import { SqlitePersistence } from "@src/lib/persistence/sqlite-persistence"; import { prosemirrorJSONToYXmlFragment } from "y-prosemirror"; import { ScreenplaySchema } from "@src/lib/screenplay/editor"; -import { JSONContent } from "@tiptap/react"; +import { TitlePageSchema } from "@src/lib/titlepage/editor"; +import { Editor, JSONContent } from "@tiptap/react"; import { createProject } from "@src/lib/utils/requests"; import { CreateProjectBody } from "@src/lib/utils/api-bodies"; import { ApiResponse } from "@src/lib/utils/api-utils"; import { CookieUser } from "@src/lib/utils/types"; import { isTauri } from "@tauri-apps/api/core"; +import { ProjectRepository } from "../project/project-repository"; export interface ImportResult { success: boolean; @@ -23,45 +25,104 @@ export interface ImportResult { } /** - * Parse a file and extract screenplay content. - * Returns the screenplay content array (guaranteed non-empty). + * Parse a file and extract project content. */ -async function parseFile(file: File): Promise { +async function parseFile(file: File): Promise { const adapter = getAdapterByFilename(file.name); if (!adapter) { throw new Error(`Unsupported file type: ${file.name.split(".").pop()}`); } const content = await file.arrayBuffer(); - const projectData = adapter.convertFrom(content); + const projectData = adapter.convertFrom(content) as ProjectData; if (!projectData.screenplay || projectData.screenplay.length === 0) { throw new Error("File appears to be empty or could not be parsed"); } - return projectData.screenplay; + return projectData; } /** - * Create a Yjs document with screenplay content and save to local persistence. + * Import a file into an existing project. */ -async function createLocalYjsDocument( - projectId: string, - screenplay: JSONContent[] +export async function importFileIntoProject( + file: File, + editor?: Editor | null, + titlePageEditor?: Editor | null, + repository?: ProjectRepository | null, ): Promise { + const adapter = getAdapterByFilename(file.name); + if (!adapter) { + throw new Error(`Unsupported file type: ${file.name.split(".").pop()}`); + } + + const content = await file.arrayBuffer(); + adapter.import(content, editor, titlePageEditor, repository); + if (editor) editor.commands.focus(); +} + +/** + * Create a Yjs document with project content and save to local persistence. + */ +async function createLocalYjsDocument(projectId: string, projectData: ProjectData): Promise { const ydoc = new ProjectState(); - // Convert the screenplay JSON to Yjs XmlFragment - const docJson: JSONContent = { - type: "doc", - content: screenplay, - }; + ydoc.transact(() => { + // Screenplay fragment + const screenplayFragment = ydoc.screenplayFragment(); + prosemirrorJSONToYXmlFragment( + ScreenplaySchema, + { type: "doc", content: projectData.screenplay }, + screenplayFragment, + ); + + // Titlepage fragment + if (projectData.titlepage) { + const titlepageFragment = ydoc.titlepageFragment(); + prosemirrorJSONToYXmlFragment( + TitlePageSchema, + { type: "doc", content: projectData.titlepage }, + titlepageFragment, + ); + } - // Get the screenplay fragment from the ydoc - const fragment = ydoc.screenplayFragment(); + // Maps + if (projectData.metadata) { + const metadataMap = ydoc.metadata(); + Object.entries(projectData.metadata).forEach(([key, value]) => metadataMap.set(key, value)); + } + + if (projectData.characters) { + const charactersMap = ydoc.characters(); + Object.entries(projectData.characters).forEach(([key, value]) => charactersMap.set(key, value)); + } + + if (projectData.locations) { + const locationsMap = ydoc.locations(); + Object.entries(projectData.locations).forEach(([key, value]) => locationsMap.set(key, value)); + } + + if (projectData.scenes) { + const scenesMap = ydoc.scenes(); + Object.entries(projectData.scenes).forEach(([key, value]) => scenesMap.set(key, value)); + } + + if (projectData.board) { + const boardMap = ydoc.board(); + Object.entries(projectData.board).forEach(([key, value]) => boardMap.set(key, value)); + } - // Use y-prosemirror to convert JSON to XmlFragment - prosemirrorJSONToYXmlFragment(ScreenplaySchema, docJson, fragment); + if (projectData.layout) { + const layoutMap = ydoc.layout(); + Object.entries(projectData.layout).forEach(([key, value]) => layoutMap.set(key, value)); + } + + if (projectData.comments) { + const commentsMap = ydoc.comments(); + Object.entries(projectData.comments).forEach(([key, value]) => commentsMap.set(key, value)); + } + }); // Save to appropriate persistence based on environment if (isTauri()) { @@ -97,11 +158,7 @@ async function createLocalYjsDocument( /** * Create a remote project via API. */ -async function createRemoteProject( - userId: string, - title: string, - description?: string -): Promise { +async function createRemoteProject(userId: string, title: string, description?: string): Promise { const body: CreateProjectBody = { title, description, @@ -129,11 +186,11 @@ async function createRemoteProject( export async function importFileAsProject( file: File, user: CookieUser | null | undefined, - title?: string + title?: string, ): Promise { try { // Parse the file content - const screenplay = await parseFile(file); + const projectData = await parseFile(file); // Create project title from filename if not provided const projectTitle = title || file.name.replace(/\.[^/.]+$/, ""); @@ -164,8 +221,8 @@ export async function importFileAsProject( projectId = localProject.id; } - // Create Yjs document with the screenplay content - await createLocalYjsDocument(projectId, screenplay); + // Create Yjs document with the project content + await createLocalYjsDocument(projectId, projectData); return { success: true, diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 6698cae..ecd42f5 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -102,11 +102,11 @@ export type ProjectData = { titlepage?: JSONContent[]; characters: any; scenes: any; - cards: any; locations: any; metadata: ProjectMetadata; board: any; layout: LayoutData; + comments?: any; }; // -------------------------------- // @@ -158,7 +158,6 @@ export class ProjectState extends Y.Doc { TITLEPAGE: "titlepage", CHARACTERS: "characters", SCENES: "scenes", - CARDS: "cards", LOCATIONS: "locations", METADATA: "metadata", BOARD: "board", @@ -202,10 +201,6 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.SCENES); } - cards(): Y.Map { - return this.getMap(this.KEYS.CARDS); - } - board(): Y.Map { return this.getMap(this.KEYS.BOARD); } From 762ac5e8c4610f59ca3271d28598f3a4fabf4ed0 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Sat, 14 Mar 2026 14:16:45 +0100 Subject: [PATCH 4/7] more typing for ProjectState, visual tweaks, pagination fixes --- components/board/BoardCanvas.tsx | 10 +- components/board/BoardCard.tsx | 12 +- .../dashboard/account/ProfileSettings.tsx | 3 +- .../preferences/AppearanceSettings.tsx | 2 + .../preferences/KeybindsSettings.tsx | 2 + .../dashboard/project/DangerZone.module.css | 4 +- .../project/LayoutSettings.module.css | 60 +++- .../dashboard/project/LayoutSettings.tsx | 313 ++++++++++-------- .../project/ProjectSettings.module.css | 7 +- .../dashboard/project/ProjectSettings.tsx | 3 +- components/editor/DocumentEditorPanel.tsx | 18 + components/popup/PopupSceneItem.tsx | 2 + components/utils/Form.module.css | 4 + messages/de.json | 2 +- messages/en.json | 8 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/ja.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- messages/zh.json | 2 +- src/lib/adapters/scriptio/scriptio-adapter.ts | 7 +- src/lib/project/project-state.ts | 54 ++- .../extensions/pagination-extension.ts | 50 +-- 24 files changed, 355 insertions(+), 218 deletions(-) diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 647fdf7..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; diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index 0844981..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; 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/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..d8d7e76 100644 --- a/components/dashboard/project/LayoutSettings.tsx +++ b/components/dashboard/project/LayoutSettings.tsx @@ -19,6 +19,9 @@ import { AlignCenter, ArrowLeftToLine, ArrowRightToLine, + ChevronUp, + ChevronDown, + Save, } from "lucide-react"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; @@ -30,6 +33,7 @@ import optionCard from "./OptionCard.module.css"; const LayoutSettings = () => { const t = useTranslations("layout"); const tCommon = useTranslations("common"); + const context = useContext(ProjectContext); const { pageFormat, setPageFormat, @@ -47,46 +51,97 @@ 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 [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); + setLocalDisplaySceneNumbers(displaySceneNumbers); + setLocalSceneNumberOnRight(sceneNumberOnRight); + setLocalHeadingSpacing(sceneHeadingSpacing); setLocalContdLabel(stripParens(contdLabel)); - }, [contdLabel]); - - useEffect(() => { setLocalMoreLabel(stripParens(moreLabel)); - }, [moreLabel]); + setLocalMargins(initialMargins); + setLocalStyles(initialStyles); + }, [pageFormat, displaySceneNumbers, sceneNumberOnRight, sceneHeadingSpacing, contdLabel, moreLabel, initialMargins, initialStyles]); + + const hasChanges = useMemo(() => { + return ( + localFormat !== pageFormat || + localDisplaySceneNumbers !== displaySceneNumbers || + localSceneNumberOnRight !== sceneNumberOnRight || + localHeadingSpacing !== sceneHeadingSpacing || + `(${localContdLabel})` !== contdLabel || + `(${localMoreLabel})` !== moreLabel || + JSON.stringify(localMargins) !== JSON.stringify(initialMargins) || + JSON.stringify(localStyles) !== JSON.stringify(initialStyles) + ); + }, [ + localFormat, pageFormat, + localDisplaySceneNumbers, displaySceneNumbers, + localSceneNumberOnRight, sceneNumberOnRight, + localHeadingSpacing, sceneHeadingSpacing, + localContdLabel, contdLabel, + localMoreLabel, moreLabel, + localMargins, initialMargins, + localStyles, initialStyles + ]); + + const handleSave = () => { + setPageFormat(localFormat); + 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 +153,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,27 +162,15 @@ 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); - - useEffect(() => { - setLocalStyles(mergedStyles); - }, [mergedStyles]); - const updateLocalStyle = (e: React.MouseEvent, element: string, styleKey: keyof ElementStyle, value: any) => { e.preventDefault(); e.stopPropagation(); @@ -158,7 +185,6 @@ const LayoutSettings = () => { [element]: { ...currentStyle, [styleKey]: value }, }; setLocalStyles(newStyles); - setElementStyles(newStyles); }; const renderElementConfig = (element: (typeof MARGIN_ELEMENTS)[number]) => { @@ -176,32 +202,56 @@ const LayoutSettings = () => { 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
@@ -270,10 +320,6 @@ const LayoutSettings = () => { ); }; - 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 +330,42 @@ 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" - /> - +
+
+ +
+ setLocalContdLabel(e.target.value)} + className={`${sharedStyles.input} ${styles.input}`} + placeholder="CONT'D" + /> +
-
-
- -
- setLocalMoreLabel(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitMoreLabel(); - }} - className={`${sharedStyles.input} ${styles.input}`} - placeholder="MORE" - /> - +
+ +
+ setLocalMoreLabel(e.target.value)} + className={`${sharedStyles.input} ${styles.input}`} + placeholder="MORE" + /> +
@@ -362,24 +388,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/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..ed7fcf9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -347,4 +347,4 @@ "titlePlaceholder": "Titel", "descriptionPlaceholder": "Beschreibung" } -} \ No newline at end of file +} diff --git a/messages/en.json b/messages/en.json index 1442210..6271113 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", @@ -286,7 +286,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 +347,4 @@ "titlePlaceholder": "Title", "descriptionPlaceholder": "Description" } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index c18d3de..b736f79 100644 --- a/messages/es.json +++ b/messages/es.json @@ -347,4 +347,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..6b8eaa5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -347,4 +347,4 @@ "titlePlaceholder": "Titre", "descriptionPlaceholder": "Description" } -} \ No newline at end of file +} diff --git a/messages/ja.json b/messages/ja.json index 5493286..b30341a 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -347,4 +347,4 @@ "titlePlaceholder": "タイトル", "descriptionPlaceholder": "説明" } -} \ No newline at end of file +} diff --git a/messages/ko.json b/messages/ko.json index 50cb796..3cdaa1b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -347,4 +347,4 @@ "titlePlaceholder": "제목", "descriptionPlaceholder": "설명" } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index 76e36cd..75424d7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -347,4 +347,4 @@ "titlePlaceholder": "Tytuł", "descriptionPlaceholder": "Opis" } -} \ No newline at end of file +} diff --git a/messages/zh.json b/messages/zh.json index d84983e..778a391 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -347,4 +347,4 @@ "titlePlaceholder": "标题", "descriptionPlaceholder": "描述" } -} \ No newline at end of file +} diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index 17bc822..d3d3d90 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,4 +1,4 @@ -import { LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state"; +import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { replaceScreenplay } from "../../screenplay/editor"; import { Editor } from "@tiptap/react"; @@ -77,7 +77,7 @@ export class ScriptioAdapter extends ProjectAdapter { characters: project.characters().toJSON(), scenes: project.scenes().toJSON(), locations: project.locations().toJSON(), - board: project.board().toJSON(), + board: project.board().toJSON() as BoardData, layout: project.layout().toJSON() as LayoutData, comments: project.comments().toJSON(), }; @@ -127,7 +127,7 @@ export class ScriptioAdapter extends ProjectAdapter { characters: tmpDoc.characters().toJSON(), scenes: tmpDoc.scenes().toJSON(), locations: tmpDoc.locations().toJSON(), - board: tmpDoc.board().toJSON(), + board: tmpDoc.board().toJSON() as BoardData, layout: tmpDoc.layout().toJSON() as LayoutData, comments: tmpDoc.comments().toJSON(), }; @@ -169,7 +169,6 @@ export class ScriptioAdapter extends ProjectAdapter { ydoc.characters().clear(); ydoc.scenes().clear(); ydoc.locations().clear(); - ydoc.cards().clear(); ydoc.board().clear(); ydoc.layout().clear(); ydoc.comments().clear(); diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index ecd42f5..eeb1a73 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -28,6 +28,10 @@ import type { ThrottledWebsocketProvider } from "../collaboration/utils"; import { ScreenplaySchema } from "../screenplay/editor"; import { TitlePageSchema } from "../titlepage/editor"; import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; +import type { CharacterItem, CharacterMap } from "../screenplay/characters"; +import type { LocationItem, LocationMap } from "../screenplay/locations"; +import type { PersistentScene, PersistentSceneMap } from "../screenplay/scenes"; +import type { Comment } from "../utils/types"; export interface ProjectYjsState { ydoc: ProjectState | null; @@ -97,16 +101,38 @@ export type LayoutData = { elementStyles: Record; }; +export interface BoardCardData { + id: string; + title: string; + description: string; + color: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface BoardArrowData { + id: string; + fromCardId: string; + toCardId: string; +} + +export type BoardData = { + cards: string; // JSON string of BoardCardData[] + arrows: string; // JSON string of BoardArrowData[] +}; + export type ProjectData = { screenplay: JSONContent[]; titlepage?: JSONContent[]; - characters: any; - scenes: any; - locations: any; + characters: CharacterMap; + scenes: PersistentSceneMap; + locations: LocationMap; metadata: ProjectMetadata; - board: any; + board: BoardData; layout: LayoutData; - comments?: any; + comments?: Record; }; // -------------------------------- // @@ -189,19 +215,19 @@ export class ProjectState extends Y.Doc { return this.getXmlFragment(this.KEYS.TITLEPAGE); } - characters(): Y.Map { + characters(): Y.Map { return this.getMap(this.KEYS.CHARACTERS); } - locations(): Y.Map { + locations(): Y.Map { return this.getMap(this.KEYS.LOCATIONS); } - scenes(): Y.Map { + scenes(): Y.Map { return this.getMap(this.KEYS.SCENES); } - board(): Y.Map { + board(): Y.Map { return this.getMap(this.KEYS.BOARD); } @@ -209,7 +235,7 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.LAYOUT); } - comments(): Y.Map { + comments(): Y.Map { return this.getMap(this.KEYS.COMMENTS); } } @@ -222,7 +248,7 @@ export class ProjectState extends Y.Doc { * Get the characters Y.Map from a ProjectState. * Convenience function for direct access without repository. */ -export const getCharactersMap = (ydoc: ProjectState): Y.Map => { +export const getCharactersMap = (ydoc: ProjectState): Y.Map => { return ydoc.characters(); }; @@ -230,7 +256,7 @@ export const getCharactersMap = (ydoc: ProjectState): Y.Map => { * Get the locations Y.Map from a ProjectState. * Convenience function for direct access without repository. */ -export const getLocationsMap = (ydoc: ProjectState): Y.Map => { +export const getLocationsMap = (ydoc: ProjectState): Y.Map => { return ydoc.locations(); }; @@ -238,7 +264,7 @@ export const getLocationsMap = (ydoc: ProjectState): Y.Map => { * Get the scenes Y.Map from a ProjectState. * Convenience function for direct access without repository. */ -export const getScenesMap = (ydoc: ProjectState): Y.Map => { +export const getScenesMap = (ydoc: ProjectState): Y.Map => { return ydoc.scenes(); }; @@ -246,7 +272,7 @@ export const getScenesMap = (ydoc: ProjectState): Y.Map => { * Get the board Y.Map from a ProjectState. * Convenience function for direct access without repository. */ -export const getBoardMap = (ydoc: ProjectState): Y.Map => { +export const getBoardMap = (ydoc: ProjectState): Y.Map => { return ydoc.board(); }; diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index e768f81..cbbf946 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -124,6 +124,7 @@ declare module "@tiptap/core" { updateHeaderContent: (left: string, right: string, pageNumber?: PageNumber) => ReturnType; updateFooterContent: (left: string, right: string, pageNumber?: PageNumber) => ReturnType; updatePageBreakBackground: (color: string) => ReturnType; + refreshPagination: () => ReturnType; }; } } @@ -226,12 +227,9 @@ function createFirstPageWidget(options: PaginationOptions): HTMLElement { *

element's content area and span the full page width. */ function getSplitPaddingVars(nodeType: ScreenplayElement): [string, string] { - switch (nodeType) { - case ScreenplayElement.Dialogue: - return ["var(--dialogue-l-margin)", "var(--dialogue-r-margin)"]; - default: // Action uses the base page margins - return ["var(--page-margin-left)", "var(--page-margin-right)"]; - } + // Screenplay elements now use element-specific margin variables (e.g., --action-l-margin) + // rather than a global page margin. + return [`var(--${nodeType}-l-margin)`, `var(--${nodeType}-r-margin)`]; } function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOptions): HTMLElement { @@ -435,8 +433,19 @@ const setupTestDiv = (editorDom: HTMLElement, options: PaginationOptions): HTMLE document.body.appendChild(testDiv); } - // Set CSS variables so the .pagination !important rules (width, padding) resolve correctly. + // Sync classes and CSS variables that affect layout from editor to test div. // testDiv lives in , not inside the editor, so it doesn't inherit the editor's CSS vars. + testDiv.className = editorDom.className; + testDiv.classList.add("pagination"); + + // Copy all CSS variables from editor to test div (margins, styles, labels) + for (let i = 0; i < editorDom.style.length; i++) { + const prop = editorDom.style[i]; + if (prop.startsWith("--")) { + testDiv.style.setProperty(prop, editorDom.style.getPropertyValue(prop)); + } + } + syncVars(testDiv, options); return testDiv; @@ -930,18 +939,13 @@ export const ScriptioPagination = Extension.create({ }, addCommands() { - const trigger = (tr: any, meta: string) => { - tr.setMeta(meta, true); - this.editor.view.dispatch(tr); - }; - return { updatePageSize: (size) => ({ tr }) => { Object.assign(this.options, size); syncVars(this.editor.view.dom, this.options); - trigger(tr, "pageFormatUpdate"); + tr.setMeta("pageFormatUpdate", true); return true; }, updatePageHeight: @@ -949,7 +953,7 @@ export const ScriptioPagination = Extension.create({ ({ tr }) => { this.options.pageHeight = h; syncVars(this.editor.view.dom, this.options); - trigger(tr, "pageFormatUpdate"); + tr.setMeta("pageFormatUpdate", true); return true; }, updatePageWidth: @@ -957,14 +961,14 @@ export const ScriptioPagination = Extension.create({ ({ tr }) => { this.options.pageWidth = w; syncVars(this.editor.view.dom, this.options); - trigger(tr, "pageFormatUpdate"); + tr.setMeta("pageFormatUpdate", true); return true; }, updatePageGap: (g) => ({ tr }) => { this.options.pageGap = g; - trigger(tr, "forcePaginationUpdate"); + tr.setMeta("forcePaginationUpdate", true); return true; }, updateMargins: @@ -977,7 +981,7 @@ export const ScriptioPagination = Extension.create({ marginRight: m.right, }); syncVars(this.editor.view.dom, this.options); - trigger(tr, "pageFormatUpdate"); + tr.setMeta("pageFormatUpdate", true); return true; }, updateHeaderContent: @@ -988,7 +992,7 @@ export const ScriptioPagination = Extension.create({ this.options.headerLeft = l; this.options.headerRight = r; } - trigger(tr, "forcePaginationUpdate"); + tr.setMeta("forcePaginationUpdate", true); return true; }, updateFooterContent: @@ -999,14 +1003,20 @@ export const ScriptioPagination = Extension.create({ this.options.footerLeft = l; this.options.footerRight = r; } - trigger(tr, "forcePaginationUpdate"); + tr.setMeta("forcePaginationUpdate", true); return true; }, updatePageBreakBackground: (c) => ({ tr }) => { this.options.pageBreakBackground = c; - trigger(tr, "forcePaginationUpdate"); + tr.setMeta("forcePaginationUpdate", true); + return true; + }, + refreshPagination: + () => + ({ tr }) => { + tr.setMeta("forcePaginationUpdate", true); return true; }, }; From ce545a5f741540db9ca1f0ea826651c32933ac7e Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Sun, 15 Mar 2026 13:54:44 +0100 Subject: [PATCH 5/7] tweaked pagination --- .../dashboard/project/LayoutSettings.tsx | 214 ++++++++++++++++-- components/editor/DocumentEditorPanel.tsx | 76 +++++-- components/projects/EmptyProjectPage.tsx | 5 +- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- src/context/ProjectContext.tsx | 60 ++++- src/lib/editor/use-document-editor.ts | 10 - src/lib/project/project-repository.ts | 12 +- src/lib/project/project-state.ts | 33 ++- .../extensions/pagination-extension.ts | 73 +++++- 16 files changed, 462 insertions(+), 85 deletions(-) diff --git a/components/dashboard/project/LayoutSettings.tsx b/components/dashboard/project/LayoutSettings.tsx index d8d7e76..5bb5167 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"; @@ -19,9 +21,12 @@ import { AlignCenter, ArrowLeftToLine, ArrowRightToLine, + ArrowUpToLine, + ArrowDownToLine, ChevronUp, ChevronDown, Save, + SeparatorHorizontal, } from "lucide-react"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; @@ -37,6 +42,8 @@ const LayoutSettings = () => { const { pageFormat, setPageFormat, + pageMargins, + setPageMargins, displaySceneNumbers, setDisplaySceneNumbers, sceneHeadingSpacing, @@ -68,6 +75,8 @@ const LayoutSettings = () => { // --- 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); @@ -100,6 +109,7 @@ const LayoutSettings = () => { // Sync when context changes externally useEffect(() => { setLocalFormat(pageFormat); + setLocalPageMargins(initialPageMargins); setLocalDisplaySceneNumbers(displaySceneNumbers); setLocalSceneNumberOnRight(sceneNumberOnRight); setLocalHeadingSpacing(sceneHeadingSpacing); @@ -107,11 +117,22 @@ const LayoutSettings = () => { setLocalMoreLabel(stripParens(moreLabel)); setLocalMargins(initialMargins); setLocalStyles(initialStyles); - }, [pageFormat, displaySceneNumbers, sceneNumberOnRight, sceneHeadingSpacing, contdLabel, moreLabel, initialMargins, 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 || @@ -121,18 +142,29 @@ const LayoutSettings = () => { JSON.stringify(localStyles) !== JSON.stringify(initialStyles) ); }, [ - localFormat, pageFormat, - localDisplaySceneNumbers, displaySceneNumbers, - localSceneNumberOnRight, sceneNumberOnRight, - localHeadingSpacing, sceneHeadingSpacing, - localContdLabel, contdLabel, - localMoreLabel, moreLabel, - localMargins, initialMargins, - localStyles, 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); @@ -171,6 +203,18 @@ const LayoutSettings = () => { })); }; + const updatePageMargin = (side: keyof PageMargin, value: string) => { + const num = parseFloat(value); + if (isNaN(num) || num < 0) return; + setLocalPageMargins((prev) => ({ ...prev, [side]: num })); + }; + + 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(); e.stopPropagation(); @@ -199,7 +243,7 @@ const LayoutSettings = () => { {t("margins")}

- + { in
- + {
+ +
+ {t("startNewPage")} +
+ +
+
); }; @@ -341,6 +399,134 @@ const LayoutSettings = () => {

+
+ +
+
+ {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 +
+
+
+
+
+
@@ -446,11 +632,7 @@ const LayoutSettings = () => {
- diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 93a7957..e3b8940 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -4,9 +4,9 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "r import { isTauri } from "@tauri-apps/api/core"; import { EditorContent } from "@tiptap/react"; -import { applyElement, insertElement } from "@src/lib/screenplay/editor"; +import { applyElement, insertElement, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor"; import { ScreenplayElement } from "@src/lib/utils/enums"; -import { DEFAULT_ELEMENT_STYLES } from "@src/lib/project/project-state"; +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"; @@ -57,6 +57,8 @@ const DocumentEditorPanel = ({ selectedElement, setSelectedElement, setSelectedStyles, + pageFormat, + pageMargins, displaySceneNumbers, sceneHeadingSpacing, sceneNumberOnRight, @@ -153,14 +155,12 @@ const DocumentEditorPanel = ({ const elementKeys = ["action", "scene", "character", "dialogue", "parenthetical", "transition", "section"] as const; for (const key of elementKeys) { - 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`); - } + 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"); @@ -168,28 +168,54 @@ const DocumentEditorPanel = ({ editorElement.style.setProperty(`--${key}-decoration`, s.underline ? "underline" : "none"); } - // Trigger re-pagination to account for height changes (spacing, margins, etc.) - if (editor.commands.refreshPagination) { - editor.commands.refreshPagination(); + // 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); } - // Sync Action margins to pagination options for header/footer alignment - if (editor.commands.updateMargins) { - const actionM = elementMargins["action"]; - if (actionM) { - editor.commands.updateMargins({ - top: 96, // 1in - bottom: 96, // 1in - left: actionM.left * 96, - right: actionM.right * 96, - }); - } + // 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, displaySceneNumbers, sceneHeadingSpacing, sceneNumberOnRight, contdLabel, moreLabel, elementMargins, elementStyles]); + }, [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); 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/messages/de.json b/messages/de.json index ed7fcf9..e8feb70 100644 --- a/messages/de.json +++ b/messages/de.json @@ -253,6 +253,11 @@ "continuation": "Fortsetzung", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Seitenränder", + "vertical": "Vertikal", + "horizontal": "Horizontal", + "marginTop": "O", + "marginBottom": "U", "margins": "Ränder", "marginLeft": "L", "marginRight": "R", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Neue Seite beginnen" }, "projectSettings": { "titleLabel": "Titel", diff --git a/messages/en.json b/messages/en.json index 6271113..ea36e19 100644 --- a/messages/en.json +++ b/messages/en.json @@ -253,6 +253,11 @@ "continuation": "Continuation", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Page Margins", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "T", + "marginBottom": "B", "margins": "Margins", "marginLeft": "L", "marginRight": "R", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Start New Page" }, "projectSettings": { "titleLabel": "Title", diff --git a/messages/es.json b/messages/es.json index b736f79..39003bb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -253,6 +253,11 @@ "continuation": "Continuación", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Márgenes de página", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "S", + "marginBottom": "I", "margins": "Márgenes", "marginLeft": "L", "marginRight": "R", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Iniciar nueva página" }, "projectSettings": { "titleLabel": "Título", diff --git a/messages/fr.json b/messages/fr.json index 6b8eaa5..4ed0787 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -253,6 +253,11 @@ "continuation": "Continuation", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Marges de page", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "H", + "marginBottom": "B", "margins": "Marges", "marginLeft": "G", "marginRight": "D", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Commencer une nouvelle page" }, "projectSettings": { "titleLabel": "Titre", diff --git a/messages/ja.json b/messages/ja.json index b30341a..dcdf008 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -253,6 +253,11 @@ "continuation": "継続", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "ページ余白", + "vertical": "垂直", + "horizontal": "水平", + "marginTop": "上", + "marginBottom": "下", "margins": "余白", "marginLeft": "左", "marginRight": "右", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "新しいページで開始" }, "projectSettings": { "titleLabel": "タイトル", diff --git a/messages/ko.json b/messages/ko.json index 3cdaa1b..b3ded20 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -253,6 +253,11 @@ "continuation": "이어지기", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "페이지 여백", + "vertical": "세로", + "horizontal": "가로", + "marginTop": "상", + "marginBottom": "하", "margins": "여백", "marginLeft": "좌", "marginRight": "우", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "새 페이지에서 시작" }, "projectSettings": { "titleLabel": "제목", diff --git a/messages/pl.json b/messages/pl.json index 75424d7..f915b7c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -253,6 +253,11 @@ "continuation": "Kontynuacja", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "Marginesy strony", + "vertical": "Pionowo", + "horizontal": "Poziomo", + "marginTop": "G", + "marginBottom": "D", "margins": "Marginesy", "marginLeft": "L", "marginRight": "P", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "Zacznij nową stronę" }, "projectSettings": { "titleLabel": "Tytuł", diff --git a/messages/zh.json b/messages/zh.json index 778a391..44c45e7 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -253,6 +253,11 @@ "continuation": "续接", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "pageMargins": "页面边距", + "vertical": "垂直", + "horizontal": "水平", + "marginTop": "上", + "marginBottom": "下", "margins": "边距", "marginLeft": "左", "marginRight": "右", @@ -274,7 +279,8 @@ "alignCenter": "Center", "alignRight": "Right", "sceneSpacing": "Spacing", - "sceneSpacingDesc": "Spacing above scene headings" + "sceneSpacingDesc": "Spacing above scene headings", + "startNewPage": "从新页面开始" }, "projectSettings": { "titleLabel": "标题", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 4ae159a..f6386a6 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -21,6 +21,8 @@ import { LayoutData, useProjectYjs, ElementStyle, + PageMargin, + DEFAULT_PAGE_MARGINS, } from "@src/lib/project/project-state"; import { Screenplay } from "@src/lib/utils/types"; import { ScreenplayElement, TitlePageElement, Style, PageFormat } from "@src/lib/utils/enums"; @@ -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; @@ -142,6 +146,8 @@ const defaultContextValue: ProjectContextType = { toggleCharacterHighlight: () => {}, pageFormat: "LETTER", setPageFormat: () => {}, + pageMargins: DEFAULT_PAGE_MARGINS, + setPageMargins: () => {}, displaySceneNumbers: false, setDisplaySceneNumbers: () => {}, sceneHeadingSpacing: 1, @@ -256,6 +262,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [selectedStyles, setSelectedStylesState] = useState