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
24 changes: 12 additions & 12 deletions components/board/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react";
import { ProjectContext } from "@src/context/ProjectContext";
import { getBoardMap, BoardCardData, BoardArrowData } from "@src/lib/project/project-state";
import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state";
import BoardCard from "./BoardCard";
import styles from "./BoardCanvas.module.css";
import { v7 as uuidv7 } from "uuid";
Expand Down Expand Up @@ -36,9 +36,9 @@ interface ArrowContextMenuState {
}

const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => {
const { repository, isYjsReady } = useContext(ProjectContext);
const { repository, isYjsReady, isReadOnly } = useContext(ProjectContext);
const t = useTranslations("board");
const ydoc = repository?.getState();
const projectState = repository?.getState();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -123,9 +123,9 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => {

// Sync cards with Yjs
useEffect(() => {
if (!ydoc || !isYjsReady) return;
if (!projectState || !isYjsReady) return;

const boardMap = getBoardMap(ydoc);
const boardMap = projectState.board();

const syncCards = () => {
const cardsData = boardMap.get("cards");
Expand Down Expand Up @@ -176,26 +176,26 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => {
return () => {
boardMap.unobserve(syncCards);
};
}, [ydoc, isYjsReady, centerCameraOnCards]);
}, [projectState, isYjsReady, centerCameraOnCards]);

// Save cards to Yjs
const saveCards = useCallback(
(newCards: BoardCardData[]) => {
if (!ydoc || !isYjsReady) return;
const boardMap = getBoardMap(ydoc);
if (!projectState || !isYjsReady || isReadOnly) return;
const boardMap = projectState.board();
boardMap.set("cards", JSON.stringify(newCards));
},
[ydoc, isYjsReady],
[projectState, isYjsReady, isReadOnly],
);

// Save arrows to Yjs
const saveArrows = useCallback(
(newArrows: BoardArrowData[]) => {
if (!ydoc || !isYjsReady) return;
const boardMap = getBoardMap(ydoc);
if (!projectState || !isYjsReady || isReadOnly) return;
const boardMap = projectState.board();
boardMap.set("arrows", JSON.stringify(newArrows));
},
[ydoc, isYjsReady],
[projectState, isYjsReady, isReadOnly],
);

// Handle keyboard events for snapping
Expand Down
25 changes: 11 additions & 14 deletions components/dashboard/project/CollaboratorsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ import styles from "./CollaboratorsSettings.module.css";
import { deleteInvite, inviteCollaborator, kickCollaborator, updateMemberRole } from "@src/lib/utils/requests";

import * as Roles from "@src/lib/utils/roles";
import { ApiResponse } from "@src/lib/utils/api-utils";
import { DashboardContext } from "@src/context/DashboardContext";
import { redirect } from "next/navigation";
import Link from "next/link";

const MAX_COLLABORATORS = 5;
Expand Down Expand Up @@ -169,18 +167,17 @@ const MemberSlot = ({ data, membership, mutateCollaborators, user }: MemberSlotP

const handleKick = async () => {
const res = await kickCollaborator(membership.project.id, data.user.id);

if (res.ok) {
if (res.status !== 204) {
// If user left the project by himself, redirect him to home
const json = (await res.json()) as ApiResponse<{ redirectUrl: string }>;
if (json.data && json.data.redirectUrl) {
closeDashboard();
redirect(json.data.redirectUrl);
}
} else {
mutateCollaborators();
}
if (!res.ok) return;

if (isSelf) {
// Self-leave: the server has already deleted the membership and
// blacklisted the user on the WS, so a 4003 close is on its way.
// The cloud-sync hook surfaces ProjectUnavailableDialog from there,
// letting the leaver decide whether to keep a local copy or discard.
// We just close the dashboard so the dialog isn't covered.
closeDashboard();
} else {
mutateCollaborators();
}
};

Expand Down
40 changes: 40 additions & 0 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { EditorContent } from "@tiptap/react";

import { applyElement, insertElement, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor";
import { ScreenplayElement } from "@src/lib/utils/enums";
import { Eye } from "lucide-react";
import { useTranslations } from "next-intl";
import { DUAL_DIALOGUE_COLUMN } from "@src/lib/screenplay/nodes/dual-dialogue-column-node";
import { DEFAULT_ELEMENT_MARGINS, DEFAULT_ELEMENT_STYLES } from "@src/lib/project/project-state";
import { join } from "@src/lib/utils/misc";
Expand Down Expand Up @@ -59,6 +61,7 @@ const DocumentEditorPanel = ({
const projectCtx = useContext(ProjectContext);
const {
isYjsReady,
isReadOnly,
selectedElement,
setSelectedElement,
setSelectedStyles,
Expand Down Expand Up @@ -124,6 +127,17 @@ const DocumentEditorPanel = ({
};
}, [editor, onEditorCreated]);

// Read-only enforcement for VIEWER role.
//
// The server already drops doc writes from viewers (see protocol.ts), but
// disabling tiptap locally avoids a confusing "I typed but nothing
// happened" experience: keystrokes are blocked at the editor level and
// collaboration carets/awareness still render normally.
useEffect(() => {
if (!editor || editor.isDestroyed) return;
editor.setEditable(!isReadOnly);
}, [editor, isReadOnly]);

// Ready state
useEffect(() => {
if (editor && isYjsReady) {
Expand Down Expand Up @@ -514,6 +528,15 @@ const DocumentEditorPanel = ({
const focusType =
focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title");

const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS];
const wrapperStyle = pageSize
? ({
"--page-width": `${pageSize.pageWidth}px`,
"--page-height": `${pageSize.pageHeight}px`,
} as React.CSSProperties)
: undefined;

const t = useTranslations("navbar");
const isLocalAccess = isTauri() || isLocalOnly;
if (!isLocalAccess && (!membership || isLoading)) return <Loading />;

Expand All @@ -524,13 +547,30 @@ const DocumentEditorPanel = ({
onScroll={onScroll}
onMouseDown={handleContainerMouseDown}
onFocus={() => setFocusedEditorType(focusType)}
onPasteCapture={
isReadOnly
? (e) => {
e.preventDefault();
e.stopPropagation();
}
: undefined
}
>
<div
className={`${styles.editor_wrapper} ${isEndlessScroll ? styles.endless_scroll : ""}`}
style={wrapperStyle}
>
<div
className={join(styles.editor_shadow, isScrolled ? styles.show_shadow : "")}
/>
{isReadOnly && (
<div className={styles.viewOnlyBannerWrapper}>
<div className={styles.viewOnlyBanner} title={t("viewOnlyHint")}>
<Eye size={14} />
<span>{t("viewOnly")}</span>
</div>
</div>
)}
<div onContextMenu={onEditorContextMenu}>
<EditorContent editor={editor} spellCheck={false} />
</div>
Expand Down
59 changes: 58 additions & 1 deletion components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@
contain: layout;
}

/* Default page-shaped sizing for the ProseMirror element so it is correctly
* dimensioned from its very first paint. The pagination extension's onCreate
* fires asynchronously (Tiptap emits 'create' via setTimeout(0)), which would
* otherwise leave a gap where .ProseMirror exists without the .pagination
* class — stretching to the full wrapper width before snapping back to the
* page width. The wrapper sets --page-width / --page-height inline based on
* pageFormat, and the !important rule from the pagination extension's
* stylesheet still wins once the class is added (with the same value, so no
* visible change). */
.editor_wrapper :global(.ProseMirror) {
width: var(--page-width);
min-height: var(--page-height);
margin: 0 auto;
box-sizing: border-box;
}

.editor_shadow {
position: sticky;
top: 0;
Expand All @@ -40,7 +56,48 @@
transition: opacity 0.7s ease;
background: linear-gradient(to bottom, var(--editor-shadow) 0%, transparent 100%);
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 20%,
black 80%,
transparent 100%
);
}

/* Zero-height sticky container so the banner overlays the editor without
* shifting any content — overflow: visible lets the banner render beyond it. */
.viewOnlyBannerWrapper {
position: sticky;
top: 0;
z-index: 51;
height: 0;
overflow: visible;
pointer-events: none;
width: 100%;
}

/* Read-only banner shown to VIEWER role at the top of the editor scroll area. */
.viewOnlyBanner {
pointer-events: auto;
width: fit-content;
margin: 0 auto;

display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 4px 14px;
border-radius: 0 0 12px 12px;

background-color: var(--main-bg);
border-top: none;
color: var(--secondary-text);
font-size: 0.75rem;
font-weight: 500;
user-select: none;
cursor: default;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.show_shadow {
Expand Down
Loading
Loading