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
57 changes: 41 additions & 16 deletions components/board/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,14 @@ 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";
import { Trash2, Plus, Minus, Copy } from "lucide-react";
import { Trash2, Plus, Minus, Copy, ListTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors";

const GRID_SIZE = 20;
const MIN_SCALE = 0.25;
const MAX_SCALE = 2;

const DEFAULT_CARD_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#6b7280",
];

interface CardContextMenuState {
position: { x: number; y: number };
card: BoardCardData;
Expand All @@ -36,7 +25,8 @@ interface ArrowContextMenuState {
}

const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => {
const { repository, isYjsReady, isReadOnly } = useContext(ProjectContext);
const { repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } =
useContext(ProjectContext);
const t = useTranslations("board");
const projectState = repository?.getState();
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -121,6 +111,17 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
setOffset({ x: newOffsetX, y: newOffsetY });
}, []);

// Focus a specific card when navigated to from the Outline. Waits until the
// board's cards have loaded and the target exists on this board, then centers
// on it and clears the request so it fires once.
useEffect(() => {
if (!boardFocusCardId || !isVisible) return;
const card = cards.find((c) => c.id === boardFocusCardId);
if (!card) return;
centerCameraOnCards([card]);
setBoardFocusCardId(null);
}, [boardFocusCardId, isVisible, cards, centerCameraOnCards, setBoardFocusCardId]);

// Sync cards with Yjs
useEffect(() => {
if (!projectState || !isYjsReady) return;
Expand Down Expand Up @@ -475,7 +476,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
const x = (e.clientX - rect.left - offset.x) / scale;
const y = (e.clientY - rect.top - offset.y) / scale;

const randomColor = DEFAULT_CARD_COLORS[Math.floor(Math.random() * DEFAULT_CARD_COLORS.length)];
const randomColor = DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)];

const newCard: BoardCardData = {
id: uuidv7(),
Expand Down Expand Up @@ -571,6 +572,23 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
[cards, saveCards],
);

// Send card to the Outline view
const handleSendToOutline = useCallback(
(card: BoardCardData) => {
repository?.addOutlineItem({
source: "card",
refDocId: docId,
refId: card.id,
title: card.title,
preview: card.description,
color: card.color,
parentId: null,
});
setCardContextMenu(null);
},
[repository, docId],
);

// Context menu for card
const handleCardContextMenu = useCallback((e: React.MouseEvent, card: BoardCardData) => {
setCardContextMenu({
Expand Down Expand Up @@ -917,7 +935,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
}}
>
<div className={styles.context_menu_colors}>
{DEFAULT_CARD_COLORS.map((color) => (
{DEFAULT_ITEM_COLORS.map((color) => (
<button
key={color}
className={`${styles.context_menu_color_swatch} ${cardContextMenu.card.color === color ? styles.context_menu_color_swatch_active : ""}`}
Expand All @@ -933,6 +951,13 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
<Copy size={16} />
<p className="unselectable">{t("duplicate")}</p>
</div>
<div
className={styles.context_menu_item}
onClick={() => handleSendToOutline(cardContextMenu.card)}
>
<ListTree size={16} />
<p className="unselectable">{t("sendToOutline")}</p>
</div>
<div
className={styles.context_menu_item}
onClick={() => handleDeleteCard(cardContextMenu.card.id)}
Expand Down
17 changes: 6 additions & 11 deletions components/editor/BoardPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"use client";

import { useContext } from "react";
import { useTranslations } from "next-intl";
import { ProjectContext } from "@src/context/ProjectContext";
import BoardCanvas from "@components/board/BoardCanvas";
import { LayoutDashboard } from "lucide-react";

Expand All @@ -20,19 +18,16 @@ const EmptyBoardState = () => {
};

/**
* Renders the board document currently selected in the document tree. The
* "board" panel is doc-aware: it reads `activeDocument` and mounts a fresh
* BoardCanvas (keyed by id) for the active board, or an empty state when the
* active document isn't a board.
* Renders the board bound to this panel side (`docId`). Each side carries its
* own docId, so two boards can be open at once. A fresh BoardCanvas is mounted
* per docId; an empty state shows when the side has no document.
*/
const BoardPanel = ({ isVisible }: { isVisible: boolean }) => {
const { activeDocument } = useContext(ProjectContext);

if (!activeDocument || activeDocument.type !== "board") {
const BoardPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => {
if (!docId) {
return <EmptyBoardState />;
}

return <BoardCanvas key={activeDocument.docId} docId={activeDocument.docId} isVisible={isVisible} />;
return <BoardCanvas key={docId} docId={docId} isVisible={isVisible} />;
};

export default BoardPanel;
22 changes: 20 additions & 2 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,24 @@ const DocumentEditorPanel = ({
}
}

// Detect a scene heading at the caret to offer "Send to outline".
// Independent of `shelving` so it works in editor documents too.
let outlineScene: { refDocId: string; refId: string; title: string } | undefined;
if (config.documentId) {
const $pos = editor.state.doc.resolve(from);
if ($pos.depth >= 1) {
const node = $pos.node(1);
const dataId = node.attrs?.["data-id"] as string | undefined;
if (node.attrs?.class === ScreenplayElement.Scene && dataId) {
outlineScene = {
refDocId: config.documentId,
refId: dataId,
title: node.textContent.toUpperCase(),
};
}
}
}

const onAddComment = () => {
if (!editor) return;
const commentId = commentOps.addComment({
Expand All @@ -516,10 +534,10 @@ const DocumentEditorPanel = ({
updateContextMenu({
type: ContextMenuType.EditorContextMenu,
position: { x: e.clientX, y: e.clientY },
typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass },
typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene },
});
},
[editor, updateContextMenu, commentOps, user, config.features.shelving],
[editor, updateContextMenu, commentOps, user, config.features.shelving, config.documentId],
);

// Clear active comment on mousedown
Expand Down
13 changes: 5 additions & 8 deletions components/editor/TreeDocumentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,10 @@ const EmptyDocumentState = () => {
);
};

const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => {
const { activeDocument, updateDocumentEditor } = useContext(ProjectContext);
const TreeDocumentPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => {
const { updateDocumentEditor } = useContext(ProjectContext);

const config = useMemo(() => {
if (!activeDocument || activeDocument.type !== "editor") return null;
return createDocumentTreeConfig(activeDocument.docId);
}, [activeDocument]);
const config = useMemo(() => (docId ? createDocumentTreeConfig(docId) : null), [docId]);

const handleEditorCreated = useCallback(
(editor: import("@tiptap/react").Editor | null) => {
Expand All @@ -35,13 +32,13 @@ const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => {
[updateDocumentEditor],
);

if (!config || !activeDocument || activeDocument.type !== "editor") {
if (!config || !docId) {
return <EmptyDocumentState />;
}

return (
<DocumentEditorPanel
key={activeDocument.docId}
key={docId}
config={config}
isVisible={isVisible}
onEditorCreated={handleEditorCreated}
Expand Down
139 changes: 139 additions & 0 deletions components/editor/outline/OutlineItem.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
.block {
position: relative;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 10px;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--separator);
border-radius: 8px;
background-color: var(--main-bg);
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
overflow: hidden;
}

.block:hover {
background-color: var(--editor-sidebar-hover);
}

.block_missing {
opacity: 0.6;
}

/* Drop indicators */
.block_drop_into {
background-color: var(--editor-style-bg-hover);
box-shadow: inset 0 0 0 2px var(--tertiary-hover);
}

.block_drop_before::before,
.block_drop_after::after {
content: "";
position: absolute;
left: 4px;
right: 4px;
height: 2px;
background-color: var(--tertiary-hover);
pointer-events: none;
}

.block_drop_before::before {
top: -4px;
}

.block_drop_after::after {
bottom: -4px;
}

.color_bar {
flex-shrink: 0;
width: 4px;
border-radius: 2px;
align-self: stretch;
}

.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}

.title_row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
}

.type_icon {
flex-shrink: 0;
color: var(--secondary-text);
}

.title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--primary-text);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.unlinked {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--secondary-text);
background-color: var(--editor-style-bg-hover);
}

.remove_btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border: none;
border-radius: 4px;
background: none;
color: var(--secondary-text);
cursor: pointer;
opacity: 0;
transition:
opacity 0.12s ease,
background-color 0.12s ease;
}

.block:hover .remove_btn {
opacity: 1;
}

.remove_btn:hover {
background-color: var(--editor-style-bg-hover);
color: var(--primary-text);
}

.preview {
margin: 0;
font-size: 12px;
color: var(--secondary-text);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
Loading
Loading