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
@@ -18,6 +18,10 @@ import type {
NormalizedBox,
Project,
RecognizeObjectResponse,
ResolveShareResponse,
SharePermission,
ShareSettings,
ShareVisitor,
TextExtractionSource,
ProjectListResponse
} from "@/domain/design";
@@ -148,6 +152,7 @@ type CandaChatHistoryResponse = {
const canvasDataPrefix = "SHAKKERDATA://";
const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId";
const shareVisitorStorageKey = "img-infinite-canvas.share.visitor";
function isVersionConflictError(error: unknown) {
return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409;
@@ -157,6 +162,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
headers.set("Content-Type", "application/json");
Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value));
const shareId = activeShareId();
if (shareId) headers.set("X-Share-Id", shareId);
let response: Response;
try {
@@ -205,6 +212,7 @@ export const designGateway = {
},
async getProject(id: string) {
if (activeShareId()) return getProjectDocument(id);
try {
const response = await request<QueryProjectResponse>("/api/canva/project/queryProject", {
method: "POST",
@@ -246,6 +254,7 @@ export const designGateway = {
},
async getProjectHistory(id: string, threadId?: string) {
if (activeShareId()) return getLegacyProjectHistory(id, threadId);
try {
const resolvedThreadId = threadId || (await designGateway.queryAgentLastThread(id));
if (!resolvedThreadId) return { projectId: id, threadId: "", messages: [] };
@@ -262,6 +271,9 @@ export const designGateway = {
},
async listAgentThreads(id: string) {
if (activeShareId()) {
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
}
try {
const response = await request<LovartAgentThreadListResponse>("/api/canva/agent/agentThreadList", {
method: "POST",
@@ -364,6 +376,7 @@ export const designGateway = {
submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record<string, unknown>) {
return request<GeneratorTaskSubmitResponse>("/api/generator/tasks", {
method: "POST",
headers: { "X-Share-Project-Id": projectId },
body: JSON.stringify({
cid: createClientId(),
project_id: projectId,
@@ -408,6 +421,53 @@ export const designGateway = {
});
},
resolveShare(shareId: string) {
return request<ResolveShareResponse>(`/api/shares/${encodeURIComponent(shareId)}`, {
headers: { "X-Share-Visitor": getShareVisitorId() }
});
},
getShareSettings(projectId: string) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing`);
},
updateShareLink(projectId: string, permission: Exclude<SharePermission, "owner">) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/link`, {
method: "PUT",
body: JSON.stringify({ permission })
});
},
inviteShareMember(projectId: string, identifier: string, permission: Exclude<SharePermission, "private" | "owner">) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members`, {
method: "POST",
body: JSON.stringify({ identifier, permission })
});
},
updateShareMember(projectId: string, memberId: string, permission: Exclude<SharePermission, "private" | "owner">) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, {
method: "PATCH",
body: JSON.stringify({ permission })
});
},
deleteShareMember(projectId: string, memberId: string) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, {
method: "DELETE"
});
},
listShareVisitors(projectId: string) {
return request<{ visitors: ShareVisitor[] }>(`/api/projects/${projectId}/sharing/visitors`);
},
revokeShareAccess(projectId: string) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/access`, {
method: "DELETE"
});
},
projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) {
const controller = new AbortController();
let settled = false;
@@ -561,6 +621,12 @@ async function getLegacyProjectHistory(id: string, threadId?: string) {
}
async function saveProjectCanvas(projectId: string, payload: object) {
if (activeShareId()) {
return request<SaveProjectResponse>(`/api/projects/${projectId}/canvas`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
try {
return await request<SaveProjectResponse>("/api/canva/project/saveProject", {
method: "POST",
@@ -575,6 +641,26 @@ async function saveProjectCanvas(projectId: string, payload: object) {
}
}
function activeShareId() {
if (typeof window === "undefined") return "";
const hashPath = window.location.hash.replace(/^#/, "");
const path = hashPath || window.location.pathname;
const [pathOnly] = path.split(/[?#]/);
const match = pathOnly.replace(/\/+$/, "").match(/^\/share\/([^/]+)$/);
return match ? decodeURIComponent(match[1]) : "";
}
function getShareVisitorId() {
if (typeof window === "undefined") return "";
const stored = window.localStorage.getItem(shareVisitorStorageKey)?.trim();
if (stored) return stored;
const bytes = new Uint8Array(18);
window.crypto.getRandomValues(bytes);
const visitorId = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
window.localStorage.setItem(shareVisitorStorageKey, visitorId);
return visitorId;
}
function messageFromCandaHistoryItem(item: CandaChatHistoryResponse["data"][number], fallbackThreadId: string): AgentMessage {
const type = item.type || "assistant";
const role: AgentMessage["role"] = type === "user" ? "user" : type === "tool_use" || item.tool_call_id ? "tool" : "assistant";