feat(canvas): add shared workspace access and controls

This commit is contained in:
2026-07-10 14:47:19 +08:00
parent f288eb1365
commit 14eaece0ac
18 changed files with 1149 additions and 80 deletions
+71 -48
View File
@@ -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}