feat(canvas): add shared workspace access and controls
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react";
|
||||
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { openHome } from "@/application/route";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design";
|
||||
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
|
||||
import { useI18n, type Locale } from "@/i18n/i18n";
|
||||
import { designGateway } from "@/infrastructure/designGateway";
|
||||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||||
@@ -29,6 +29,7 @@ import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/com
|
||||
import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar";
|
||||
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
|
||||
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||||
import { SharePopover } from "@/ui/components/SharePopover";
|
||||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||
@@ -526,9 +527,25 @@ function cleanCanvasAvatarUrl(value: string | undefined) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
const ownerWorkspaceAccess: ShareAccess = {
|
||||
permission: "owner",
|
||||
source: "owner",
|
||||
authenticated: true,
|
||||
isOwner: true,
|
||||
capabilities: {
|
||||
canViewCanvas: true,
|
||||
canViewChat: true,
|
||||
canEdit: true,
|
||||
canManage: true
|
||||
}
|
||||
};
|
||||
|
||||
export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) {
|
||||
const { t, locale } = useI18n();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const canEdit = shareAccess.capabilities.canEdit;
|
||||
const canViewChat = shareAccess.capabilities.canViewChat;
|
||||
const canManageSharing = shareAccess.capabilities.canManage;
|
||||
const toUserMessageText = useUserMessage();
|
||||
const { showToast } = useGlobalToast();
|
||||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
||||
@@ -556,7 +573,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [agentThreads, setAgentThreads] = useState<AgentThreadSummary[]>([]);
|
||||
const [credits, setCredits] = useState(30);
|
||||
const [assistantPanelOpen, setAssistantPanelOpen] = useState(true);
|
||||
const [assistantPanelOpen, setAssistantPanelOpen] = useState(canViewChat);
|
||||
const [agentHistoryOpen, setAgentHistoryOpen] = useState(false);
|
||||
const [agentHistorySearch, setAgentHistorySearch] = useState("");
|
||||
const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null);
|
||||
@@ -679,6 +696,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
.catch(() => undefined);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewChat) {
|
||||
setAssistantPanelOpen(false);
|
||||
setAgentHistoryOpen(false);
|
||||
}
|
||||
}, [canViewChat]);
|
||||
|
||||
useEffect(() => {
|
||||
webSearchHydratedRef.current = false;
|
||||
const stored = window.localStorage.getItem(canvasBackgroundStorageKey(projectId));
|
||||
@@ -734,6 +758,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
});
|
||||
|
||||
const loadAgentThreads = useCallback(async () => {
|
||||
if (!canViewChat) return;
|
||||
const currentProject = projectRef.current;
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
@@ -742,7 +767,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
} catch (err) {
|
||||
console.warn("Failed to load agent thread history", err);
|
||||
}
|
||||
}, [projectRef]);
|
||||
}, [canViewChat, projectRef]);
|
||||
|
||||
const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({
|
||||
project,
|
||||
@@ -753,6 +778,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
nodesRef,
|
||||
dirty,
|
||||
anyGenerating,
|
||||
disabled: !canEdit,
|
||||
setProject,
|
||||
setDirty,
|
||||
setSaving,
|
||||
@@ -825,7 +851,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject));
|
||||
webSearchHydratedRef.current = true;
|
||||
}
|
||||
if (isProjectGenerating(nextProject)) {
|
||||
if (isProjectGenerating(nextProject) && canViewChat) {
|
||||
const channel: GenerationChannel = projectHasImageGeneratorGeneration(nextProject) ? "image-generator" : projectHasCanvasActionGeneration(nextProject) ? "canvas-action" : "agent";
|
||||
if (channel === "image-generator") {
|
||||
setImageGeneratorGenerating(true);
|
||||
@@ -840,7 +866,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
startProjectEvents(nextProject.id, nextProject.lastThreadId, channel);
|
||||
}
|
||||
}
|
||||
return designGateway.getProjectHistory(nextProject.id);
|
||||
return canViewChat ? designGateway.getProjectHistory(nextProject.id) : null;
|
||||
})
|
||||
.then((history) => {
|
||||
if (history && !agentStreamingProjectIdRef.current) {
|
||||
@@ -867,7 +893,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
setError(toUserMessageText(err, "generateError"));
|
||||
});
|
||||
}, [applyProject, projectId, startProjectEvents]);
|
||||
}, [applyProject, canViewChat, projectId, startProjectEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
referenceImagesRef.current = referenceImages;
|
||||
@@ -920,7 +946,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}, [agentHistorySearch, agentHistoryThreads]);
|
||||
const selectedCanvasModel = imageModels.find((model) => model.id === selectedCanvasModelId) ?? imageModels[0];
|
||||
const activeHistoryThreadId = selectedAgentThreadId === newAgentConversationId ? newAgentConversationId : selectedAgentThreadId || agentHistoryThreads[0]?.agentThreadId || "";
|
||||
const selectedNodeControlsEnabled = selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
|
||||
const selectedNodeControlsEnabled = canEdit && selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
|
||||
const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null;
|
||||
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
|
||||
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
|
||||
@@ -1234,9 +1260,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
};
|
||||
|
||||
const showCanvasToast = (message: string, tone: "success" | "error" = "success") => {
|
||||
const showCanvasToast = useCallback((message: string, tone: "success" | "error" = "success") => {
|
||||
showToast(message, tone);
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
const attachFrameChildren = (current: CanvasNode[], candidateIds: Set<string>) => attachNodesToContainingFrames(current, candidateIds);
|
||||
|
||||
@@ -2278,6 +2304,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableShortcutTarget(event.target)) return;
|
||||
if (!canEdit) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const primary = isPrimaryShortcut(event);
|
||||
@@ -2343,6 +2370,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (isEditableShortcutTarget(event.target)) return;
|
||||
if (!canEdit) return;
|
||||
if (!event.clipboardData) return;
|
||||
if (pasteFromClipboardData(event.clipboardData)) {
|
||||
event.preventDefault();
|
||||
@@ -2496,6 +2524,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
const chooseCanvasTool = (tool: CanvasTool) => {
|
||||
if (!canEdit) return;
|
||||
setActiveTool(tool);
|
||||
setCanvasToolMenuOpen(false);
|
||||
if (tool !== "shape") setShapeToolMenuOpen(false);
|
||||
@@ -2532,24 +2561,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
chooseCanvasTool("shape");
|
||||
};
|
||||
|
||||
const shareProject = () => {
|
||||
if (!project) return;
|
||||
const shareUrl = window.location.href;
|
||||
const shareTitle = project.title || t("canvasUntitled");
|
||||
if (navigator.share) {
|
||||
void navigator.share({ title: shareTitle, url: shareUrl }).catch((err: unknown) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
showCanvasToast(t("shareUnavailable"), "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
showCanvasToast(t("shareUnavailable"), "error");
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(shareUrl).then(() => showCanvasToast(t("shareLinkCopied"))).catch(() => showCanvasToast(t("shareUnavailable"), "error"));
|
||||
};
|
||||
|
||||
const syncReferenceImages = (references: UploadedReferenceImage[]) => {
|
||||
referenceImagesRef.current = references;
|
||||
setReferenceImages(references);
|
||||
@@ -3415,7 +3426,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`workspace-shell ${assistantPanelOpen ? "" : "assistant-panel-collapsed"}`}>
|
||||
<div className={`workspace-shell ${assistantPanelOpen && canViewChat ? "" : "assistant-panel-collapsed"} ${canEdit ? "" : "is-readonly"} ${canViewChat ? "" : "is-canvas-only"}`}>
|
||||
<header className="workspace-topbar">
|
||||
<div className="workspace-countdown" aria-label={t("promo")}>
|
||||
<span>0</span>
|
||||
@@ -3442,13 +3453,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
|
||||
ref={containerRef}
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={handleBackgroundPointerDown}
|
||||
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
onDragOver={canEdit ? handleCanvasDragOver : undefined}
|
||||
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
||||
onDrop={canEdit ? handleCanvasDrop : undefined}
|
||||
>
|
||||
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden onChange={handleCanvasImageInput} />
|
||||
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden disabled={!canEdit} onChange={handleCanvasImageInput} />
|
||||
<div className="workspace-stage-title">
|
||||
<WorkspaceTitle
|
||||
title={project.title}
|
||||
@@ -3458,14 +3469,24 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
backHomeLabel={t("backHome")}
|
||||
copyLabel={t("copyProject")}
|
||||
moreLabel={t("more")}
|
||||
disabled={!canEdit}
|
||||
onBackHome={openHome}
|
||||
onRename={updateProjectTitle}
|
||||
/>
|
||||
</div>
|
||||
{assistantPanelOpen ? (
|
||||
<CanvasAccountControls credits={credits} />
|
||||
{canViewChat ? (
|
||||
assistantPanelOpen ? (
|
||||
<CanvasAccountControls credits={credits} />
|
||||
) : (
|
||||
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
|
||||
)
|
||||
) : (
|
||||
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
|
||||
<CanvasAccountControls credits={credits} />
|
||||
)}
|
||||
{!canViewChat && (
|
||||
<div className="canvas-share-control">
|
||||
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
|
||||
</div>
|
||||
)}
|
||||
{layersPanelOpen && (
|
||||
<CanvasLayerPanel
|
||||
@@ -3499,7 +3520,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
{renderedCanvasNodes.map((node) => {
|
||||
const hidden = hiddenNodeIds.has(node.id);
|
||||
const locked = lockedNodeIds.has(node.id);
|
||||
const canOperateNode = !locked;
|
||||
const canOperateNode = canEdit && !locked;
|
||||
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id;
|
||||
const imageGeneratorTargetSize =
|
||||
node.type === "image" && node.layerRole === "image-generator"
|
||||
@@ -3829,6 +3850,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!canEdit && <div className="workspace-readonly-shield" aria-hidden="true" />}
|
||||
|
||||
<div className={`canvas-bottom-left ${generatedFilesPanelOpen ? "is-offset-by-files-panel" : layersPanelOpen ? "is-offset-by-layers-panel" : ""}`}>
|
||||
{showMinimap && (
|
||||
<CanvasMinimap
|
||||
@@ -3844,7 +3867,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
onViewportCommit={scheduleCanvasSave}
|
||||
/>
|
||||
)}
|
||||
<div className="canvas-statusbar">
|
||||
<div className={`canvas-statusbar ${canEdit ? "" : "is-disabled"}`} aria-disabled={!canEdit} inert={!canEdit}>
|
||||
<CanvasColorPopover
|
||||
title={t("canvasBackgroundColor")}
|
||||
value={canvasBackgroundColor}
|
||||
@@ -3904,6 +3927,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
shapeToolMenuOpen={shapeToolMenuOpen}
|
||||
selectedShapeKind={selectedShapeKind}
|
||||
showGrid={showGrid}
|
||||
disabled={!canEdit}
|
||||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||||
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
|
||||
onShapeToolMenuOpenChange={setShapeToolMenuOpen}
|
||||
@@ -3912,7 +3936,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
onToggleGrid={() => setShowGrid((value) => !value)}
|
||||
onUploadImage={() => canvasImageInputRef.current?.click()}
|
||||
/>
|
||||
{activeTool === "pen" && (
|
||||
{canEdit && activeTool === "pen" && (
|
||||
<PenToolControls
|
||||
settings={penSettings}
|
||||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||||
@@ -3921,14 +3945,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assistantPanelOpen && (
|
||||
{canViewChat && assistantPanelOpen && (
|
||||
<aside className="assistant-panel">
|
||||
<div className="assistant-header">
|
||||
<div className="assistant-header-title">
|
||||
<strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
|
||||
</div>
|
||||
<div className="assistant-header-actions">
|
||||
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
|
||||
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!canEdit || !hasCurrentConversation}>
|
||||
<MessageSquarePlus size={16} />
|
||||
</button>
|
||||
<Popover open={agentHistoryOpen} onOpenChange={setAgentHistoryOpen}>
|
||||
@@ -3949,7 +3973,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</label>
|
||||
<div className="agent-history-list" data-testid="agent-history-menu">
|
||||
{!agentHistorySearch.trim() && (
|
||||
{canEdit && !agentHistorySearch.trim() && (
|
||||
<div
|
||||
className={`agent-history-item ${activeHistoryThreadId === newAgentConversationId ? "active" : ""}`}
|
||||
role="button"
|
||||
@@ -3985,6 +4009,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
<button
|
||||
className="agent-history-delete"
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
aria-label={t("deleteConversation")}
|
||||
title={t("deleteConversation")}
|
||||
onClick={(event) => {
|
||||
@@ -4002,9 +4027,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button className="assistant-header-icon" type="button" aria-label={t("share")} title={t("share")} onClick={shareProject} disabled={!hasCurrentConversation}>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
|
||||
<span className="assistant-header-separator" aria-hidden="true" />
|
||||
<button className="assistant-header-icon" type="button" aria-label={t("collapse")} title={t("collapse")} onClick={() => setAssistantPanelOpen(false)}>
|
||||
<PanelRightClose size={16} />
|
||||
@@ -4029,7 +4052,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="assistant-footer-dock" ref={assistantFooterRef}>
|
||||
<div className={`assistant-footer-dock ${canEdit ? "" : "is-disabled"}`} ref={assistantFooterRef} aria-disabled={!canEdit} inert={!canEdit}>
|
||||
<CanvasAgentComposer
|
||||
ref={agentComposerRef}
|
||||
text={prompt}
|
||||
|
||||
Reference in New Issue
Block a user