Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions components/dashboard/project/ExportProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const ExportProject = () => {

const [format, setFormat] = useState<ExportFormat>(ExportFormat.PDF);
const [includeWatermark, setIncludeWatermark] = useState<boolean>(false);
const [watermarkText, setWatermarkText] = useState<string>(
membership?.project.author || localAuthor || ""
);
const [includeNotes, setIncludeNotes] = useState<boolean>(false);
const [enablePassword, setEnablePassword] = useState<boolean>(false);
const [password, setPassword] = useState<string>("");
Expand Down Expand Up @@ -112,7 +115,7 @@ const ExportProject = () => {
const pdfOptions: PDFExportOptions = {
...baseOptions,
format: pageFormat === "A4" ? "A4" : "LETTER",
watermark: includeWatermark,
watermarkText: includeWatermark ? (watermarkText || undefined) : undefined,
password: enablePassword && password ? password : undefined,
displaySceneNumbers,
sceneHeadingSpacing,
Expand Down Expand Up @@ -238,16 +241,29 @@ const ExportProject = () => {
{/* Watermark Toggle (PDF Only) */}
{format === ExportFormat.PDF && (
<div
className={`${optionCard.optionCard} ${includeWatermark ? optionCard.active : ""}`}
className={`${optionCard.optionCard} ${optionCard.optionCardExpandable} ${
includeWatermark ? optionCard.active : ""
}`}
onClick={() => setIncludeWatermark(!includeWatermark)}
>
<div className={optionCard.checkbox}>
{includeWatermark && <div className={optionCard.checkInner} />}
</div>
<div className={optionCard.optionInfo}>
<span className={optionCard.optionTitle}>{t("watermark")}</span>
<span className={optionCard.optionDesc}>{t("watermarkDesc")}</span>
<div className={optionCard.optionRow}>
<div className={optionCard.checkbox}>
{includeWatermark && <div className={optionCard.checkInner} />}
</div>
<div className={optionCard.optionInfo}>
<span className={optionCard.optionTitle}>{t("watermark")}</span>
<span className={optionCard.optionDesc}>{t("watermarkDesc")}</span>
</div>
</div>
{includeWatermark && (
<input
value={watermarkText}
onChange={(e) => setWatermarkText(e.target.value)}
placeholder={t("watermarkPlaceholder")}
className={`${sharedStyles.input} ${styles.passwordInput}`}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
)}

Expand Down
38 changes: 38 additions & 0 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useUser } from "@src/lib/utils/hooks";
import CommentCards from "@components/editor/CommentCards";
import Loading from "@components/utils/Loading";

import { TextSelection } from "@tiptap/pm/state";
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";
Expand Down Expand Up @@ -274,6 +275,43 @@ const DocumentEditorPanel = ({
return true;
}

if (currNode === ScreenplayElement.Dialogue && nodePos > 0 && nodePos < nodeSize) {
const doc = view.state.doc;
const $anchor = selection.$anchor;

// Find the nearest preceding Character node
let charName = "";
for (let i = $anchor.index(0) - 1; i >= 0; i--) {
const child = doc.child(i);
if (child.attrs.class === ScreenplayElement.Character) {
charName = child.textContent;
break;
}
if (child.attrs.class !== ScreenplayElement.Parenthetical && child.attrs.class !== ScreenplayElement.Dialogue) break;
}

const schema = view.state.schema;
const secondHalf = node.content.cut(nodePos);

const charNode = schema.nodes[ScreenplayElement.Character].create(
{ class: ScreenplayElement.Character, height: null },
charName ? schema.text(charName) : undefined,
);
const newDialogue = schema.nodes[ScreenplayElement.Dialogue].create(
{ class: ScreenplayElement.Dialogue, height: null },
secondHalf.size > 0 ? secondHalf : undefined,
);

const tr = view.state.tr;
tr.delete($anchor.pos, $anchor.end(1));
const insertPos = tr.mapping.map($anchor.after(1));
tr.insert(insertPos, [charNode, newDialogue]);
tr.setSelection(TextSelection.create(tr.doc, insertPos + charNode.nodeSize + 1));
tr.scrollIntoView();
view.dispatch(tr);
return true;
}

if (nodePos < nodeSize) return false;

let newNode = ScreenplayElement.Action;
Expand Down
17 changes: 12 additions & 5 deletions components/editor/SuggestionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { useContext, useEffect, useState, useCallback, useRef } from "react";

import styles from "./SuggestionMenu.module.css";
import { pasteTextAt } from "@src/lib/screenplay/editor";
import { pasteTextAt, insertElement } from "@src/lib/screenplay/editor";
import { ScreenplayElement } from "@src/lib/utils/enums";
import { ProjectContext } from "@src/context/ProjectContext";

type Props = {
Expand Down Expand Up @@ -79,6 +80,14 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => {
pasteTextAt(editor, suggestion, data.cursor);
onSelect?.();
}

if (data.nodeType === "character") {
const afterPos = editor.state.selection.$anchor.after();
const nextNode = editor.state.doc.nodeAt(afterPos);
if (!nextNode || nextNode.attrs.class !== ScreenplayElement.Dialogue) {
insertElement(editor, ScreenplayElement.Dialogue, afterPos);
}
}
},
[editor, onSelect],
);
Expand All @@ -97,10 +106,8 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => {
e.stopImmediatePropagation();
setSelectedIdx((prev) => (prev + 1) % len);
} else if (e.key === "Enter") {
if (suggestionDataRef.current.nodeType !== "character") {
e.preventDefault();
e.stopImmediatePropagation();
}
e.preventDefault();
e.stopImmediatePropagation();
selectSuggestion(selectedIdxRef.current);
}
};
Expand Down
2 changes: 1 addition & 1 deletion components/project/ProjectWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const ProjectWorkspace = () => {
<div className={`${styles.workspace} ${!isZenMode ? styles.sidebars_visible : ""}`}>
{/* Overlays */}
<ContextMenu />
{suggestions.length > 0 && <SuggestionMenu suggestions={suggestions} suggestionData={suggestionData} />}
{suggestions.length > 0 && <SuggestionMenu suggestions={suggestions} suggestionData={suggestionData} onSelect={() => updateSuggestions([])} />}
<Popup />

{/* Left sidebar - only show when screenplay is visible */}
Expand Down
3 changes: 2 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "Menschenlesbares JSON",
"readableDesc": "Als einfaches JSON statt komprimiertem Binärformat exportieren. Größere Datei, mit jedem Texteditor inspizierbar.",
"watermark": "Wasserzeichen",
"watermarkDesc": "Den Namen des Autors auf den Seiten einblenden.",
"watermarkDesc": "Text auf den Seiten einblenden.",
"watermarkPlaceholder": "Wasserzeichen-Text",
"passwordProtection": "Kennwortschutz",
"passwordProtectionDesc": "Ein Kennwort zum Öffnen der PDF-Datei erforderlich.",
"passwordPlaceholder": "Kennwort eingeben",
Expand Down
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "Human-readable JSON",
"readableDesc": "Export as plain JSON instead of compressed binary. Larger file, inspectable with any text editor.",
"watermark": "Watermark",
"watermarkDesc": "Overlay the author's name on pages.",
"watermarkDesc": "Overlay text on pages.",
"watermarkPlaceholder": "Watermark text",
"passwordProtection": "Password Protection",
"passwordProtectionDesc": "Require a password to open the PDF.",
"passwordPlaceholder": "Enter password",
Expand Down
3 changes: 2 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "JSON legible",
"readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.",
"watermark": "Marca de agua",
"watermarkDesc": "Superponer el nombre del autor en las páginas.",
"watermarkDesc": "Superponer texto en las páginas.",
"watermarkPlaceholder": "Texto de marca de agua",
"passwordProtection": "Protección con contraseña",
"passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.",
"passwordPlaceholder": "Introduce la contraseña",
Expand Down
3 changes: 2 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "JSON lisible",
"readableDesc": "Exporter en JSON brut plutôt qu'en binaire compressé. Fichier plus volumineux, inspectable avec n'importe quel éditeur de texte.",
"watermark": "Filigrane",
"watermarkDesc": "Superposer le nom de l'auteur sur les pages.",
"watermarkDesc": "Superposer du texte sur les pages.",
"watermarkPlaceholder": "Texte du filigrane",
"passwordProtection": "Protection par mot de passe",
"passwordProtectionDesc": "Exiger un mot de passe pour ouvrir le PDF.",
"passwordPlaceholder": "Entrer le mot de passe",
Expand Down
3 changes: 2 additions & 1 deletion messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "人間が読めるJSON",
"readableDesc": "圧縮バイナリの代わりにプレーンJSONでエクスポートします。ファイルサイズは大きくなりますが、任意のテキストエディタで確認できます。",
"watermark": "透かし",
"watermarkDesc": "ページに著者名を重ねて表示します。",
"watermarkDesc": "ページにテキストを重ねて表示します。",
"watermarkPlaceholder": "透かしテキスト",
"passwordProtection": "パスワード保護",
"passwordProtectionDesc": "PDFを開くためにパスワードが必要です。",
"passwordPlaceholder": "パスワードを入力",
Expand Down
3 changes: 2 additions & 1 deletion messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "사람이 읽을 수 있는 JSON",
"readableDesc": "압축 바이너리 대신 일반 JSON으로 내보냅니다. 파일 크기가 크지만 어떤 텍스트 편집기로도 열어볼 수 있습니다.",
"watermark": "워터마크",
"watermarkDesc": "페이지에 작가 이름을 오버레이합니다.",
"watermarkDesc": "페이지에 텍스트를 오버레이합니다.",
"watermarkPlaceholder": "워터마크 텍스트",
"passwordProtection": "비밀번호 보호",
"passwordProtectionDesc": "PDF를 열 때 비밀번호가 필요합니다.",
"passwordPlaceholder": "비밀번호 입력",
Expand Down
3 changes: 2 additions & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "Czytelny JSON",
"readableDesc": "Eksportuj jako zwykły JSON zamiast skompresowanego formatu binarnego. Większy plik, możliwy do podglądu w dowolnym edytorze tekstu.",
"watermark": "Znak wodny",
"watermarkDesc": "Nakładanie nazwy autora na strony.",
"watermarkDesc": "Nakładanie tekstu na strony.",
"watermarkPlaceholder": "Tekst znaku wodnego",
"passwordProtection": "Ochrona hasłem",
"passwordProtectionDesc": "Wymagaj hasła do otwarcia pliku PDF.",
"passwordPlaceholder": "Wprowadź hasło",
Expand Down
3 changes: 2 additions & 1 deletion messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"readable": "人类可读的 JSON",
"readableDesc": "以纯 JSON 格式导出,而非压缩二进制格式。文件较大,可用任何文本编辑器查看。",
"watermark": "水印",
"watermarkDesc": "在页面上叠加作者姓名。",
"watermarkDesc": "在页面上叠加文本。",
"watermarkPlaceholder": "水印文字",
"passwordProtection": "密码保护",
"passwordProtectionDesc": "需要密码才能打开 PDF。",
"passwordPlaceholder": "输入密码",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/adapters/pdf/pdf-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension"

export type PDFExportOptions = BaseExportOptions & {
format: PageFormat;
watermark: boolean;
watermarkText?: string;
password?: string;
displaySceneNumbers?: boolean;
sceneHeadingBold?: boolean;
Expand Down Expand Up @@ -105,7 +105,7 @@ export class PDFAdapter extends ProjectAdapter<PDFExportOptions> {
baseUrl: BASE_URL,
pageWidth: pdfPageSize.width,
pageHeight: pdfPageSize.height,
watermark: options.watermark,
watermarkText: options.watermarkText,
password: options.password,
author: options.author,
titlePageLines,
Expand Down
28 changes: 20 additions & 8 deletions src/lib/adapters/pdf/pdf.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export interface WorkerPayload {
baseUrl: string;
pageWidth: number; // PDF page width in points
pageHeight: number; // PDF page height in points
watermark: boolean;
watermarkText?: string;
password?: string;
author: string;
titlePageLines: VisualLine[];
Expand Down Expand Up @@ -276,7 +276,7 @@ async function renderLines(
}

// Watermark on the page we are leaving
if (payload.watermark) drawWatermark(doc, pageSize, payload.author);
if (payload.watermarkText) drawWatermark(doc, pageSize, payload.watermarkText);

doc.addPage();
currentPage++;
Expand Down Expand Up @@ -370,7 +370,7 @@ async function renderLines(
}
}

if (payload.watermark) drawWatermark(doc, pageSize, payload.author);
if (payload.watermarkText) drawWatermark(doc, pageSize, payload.watermarkText);
}

/**
Expand Down Expand Up @@ -433,10 +433,22 @@ function drawWatermark(doc: jsPDF, pageSize: { width: number; height: number },
doc.setFont("CourierPrime", "bold");
doc.setFontSize(54);
doc.setTextColor(128, 128, 128);
doc.text(text, pageSize.width / 2, pageSize.height / 2, {
align: "center",
baseline: "middle",
angle: 45,
});

// jsPDF shifts x by -textWidth/2 for align:"center" *before* applying the rotation,
// so the rotation pivot ends up at the text's left edge rather than the page centre.
// Instead, compute the start position manually so the visual midpoint of the
// rotated text lands at (cx, cy).
// For angle:45 (CCW = upper-right in screen space), text advances in direction
// (cos 45°, −sin 45°) in Y-down page coordinates:
// cx = x0 + (textWidth/2) × cos45 → x0 = cx − (textWidth/2) × cos45
// cy = y0 − (textWidth/2) × sin45 → y0 = cy + (textWidth/2) × sin45
const textWidth = doc.getTextWidth(text);
const cx = pageSize.width / 2;
const cy = pageSize.height / 2;
const rad = Math.PI / 4;
const x0 = cx - (textWidth / 2) * Math.cos(rad);
const y0 = cy + (textWidth / 2) * Math.sin(rad);

doc.text(text, x0, y0, { angle: 45 });
doc.restoreGraphicsState();
}
39 changes: 39 additions & 0 deletions src/lib/editor/use-document-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { ySyncPluginKey, yUndoPluginKey } from "@tiptap/y-tiptap";

import { ProjectContext } from "@src/context/ProjectContext";
import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums";
Expand Down Expand Up @@ -438,6 +439,44 @@ export const useDocumentEditor = (
}
}, [user?.username, user?.color, provider]);

// Fix Yjs undo cursor restoration: y-tiptap's stack-item-popped fires AFTER
// the undo transaction commits, so beforeTransactionSelection is captured wrong
// by beforeAllTransactions. Patch undo/redo to pre-set it from the stack item.
useEffect(() => {
if (!editor || !isYjsReady) return;

const state = editor.state;
const yUndoState = yUndoPluginKey.getState(state);
const ySyncState = ySyncPluginKey.getState(state);
if (!yUndoState?.undoManager || !ySyncState?.binding) return;

const um = yUndoState.undoManager;
const binding = ySyncState.binding;
const originalUndo = um.undo.bind(um);
const originalRedo = um.redo.bind(um);

um.undo = () => {
if (um.undoStack.length > 0) {
const prevSel = um.undoStack[um.undoStack.length - 1].meta.get(binding);
if (prevSel) binding.beforeTransactionSelection = prevSel;
}
return originalUndo();
};

um.redo = () => {
if (um.redoStack.length > 0) {
const prevSel = um.redoStack[um.redoStack.length - 1].meta.get(binding);
if (prevSel) binding.beforeTransactionSelection = prevSel;
}
return originalRedo();
};

return () => {
um.undo = originalUndo;
um.redo = originalRedo;
};
}, [editor, isYjsReady]);

// Refresh character highlights
useEffect(() => {
if (editor && features.characterHighlights) {
Expand Down
Loading