diff --git a/README.md b/README.md index 1260b13a3..c0b87b10e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ https://www.fhl.mom/register?aff=86L574B4T2N9 (包含codex和GPT image 2模 功能请求/功能更新/视频教程/联系我,都可以在B站评论或私信:https://space.bilibili.com/78652351 +## 画布导入 + +画布列表页支持导入 `.json` 和 `.zip` 格式的画布文件。点击顶部工具栏的导入按钮,或将文件拖到画布列表区域,即可导入到当前项目。 + +导入 zip 时会读取 `canvas.json` 和 `resources-manifest.json`,自动恢复资源引用;已存在的同名同大小资源会按哈希复用,避免重复写入。导入后的画布会生成新的 ID,并放置到当前视图附近的空白位置,避免覆盖原有画布。 + ---- 【新增了version文件,我每次更新都会更新version的版本号,如果你下载version文件,打开项目后,导航栏的GitHub按键就会提示新版本,如果不想查看更新提示,就删除version文件】 diff --git a/main.py b/main.py index fb2784035..61417b1e1 100644 --- a/main.py +++ b/main.py @@ -3196,6 +3196,142 @@ def new_canvas(title="未命名画布", icon="layers", kind="classic", project=N save_canvas(canvas) return canvas + +def imported_canvas_payload(source, project=None, board_x=None, board_y=None, resource_mapping=None): + if not isinstance(source, dict): + raise HTTPException(status_code=400, detail="画布文件格式不正确") + if not isinstance(source.get("nodes"), list): + raise HTTPException(status_code=400, detail="画布 JSON 缺少 nodes") + connections = source.get("connections") + if not isinstance(connections, list): + connections = [] + timestamp = now_ms() + canvas = json.loads(json.dumps(source, ensure_ascii=False)) + if resource_mapping: + canvas = canvas_workflow_replace_strings(canvas, resource_mapping) + original_title = str(canvas.get("title") or "导入画布").strip()[:72] or "导入画布" + old_id = str(canvas.get("id") or "") + canvas["id"] = uuid.uuid4().hex + canvas["title"] = f"{original_title}(导入)"[:80] + canvas["kind"] = normalize_canvas_kind(canvas.get("kind")) + canvas["project"] = str(project or "").strip() or DEFAULT_PROJECT_ID + canvas["created_at"] = timestamp + canvas["updated_at"] = timestamp + canvas["deleted_at"] = 0 + canvas["pinned"] = False + canvas["nodes"] = canvas.get("nodes") if isinstance(canvas.get("nodes"), list) else [] + canvas["connections"] = connections + canvas["viewport"] = canvas.get("viewport") if isinstance(canvas.get("viewport"), dict) else {"x": 0, "y": 0, "scale": 1} + canvas["settings"] = canvas.get("settings") if isinstance(canvas.get("settings"), dict) else {} + canvas["logs"] = canvas.get("logs") if isinstance(canvas.get("logs"), list) else [] + if board_x is not None: + canvas["board_x"] = float(board_x) + else: + canvas.pop("board_x", None) + if board_y is not None: + canvas["board_y"] = float(board_y) + else: + canvas.pop("board_y", None) + canvas["imported_from"] = old_id + save_canvas(canvas) + return canvas + + +def read_canvas_import_archive(raw: bytes, filename: str): + resource_mapping = {} + imported_resources = [] + lower = str(filename or "").lower() + if lower.endswith(".zip") or raw[:2] == b"PK": + try: + with zipfile.ZipFile(BytesIO(raw), "r") as zf: + names = zf.namelist() + canvas_name = "canvas.json" if "canvas.json" in names else next((n for n in names if n.lower().endswith("/canvas.json")), "") + if not canvas_name: + raise HTTPException(status_code=400, detail="压缩包中没有 canvas.json") + canvas = json.loads(zf.read(canvas_name).decode("utf-8-sig")) + manifest = {} + manifest_name = "resources-manifest.json" if "resources-manifest.json" in names else next((n for n in names if n.lower().endswith("/resources-manifest.json")), "") + if manifest_name: + try: + manifest = json.loads(zf.read(manifest_name).decode("utf-8-sig")) + except Exception: + manifest = {} + resources = manifest.get("resources") if isinstance(manifest, dict) else [] + if not isinstance(resources, list): + resources = [] + stamp = time.strftime("%Y%m%d-%H%M%S") + import_dir = os.path.join(OUTPUT_INPUT_DIR, f"canvas_import_{stamp}_{uuid.uuid4().hex[:6]}") + for index, res in enumerate(resources): + if not isinstance(res, dict) or res.get("skipped"): + continue + archive = str(res.get("file") or res.get("archive") or "").replace("\\", "/").lstrip("/") + if not archive or archive not in names: + continue + base = sanitize_export_filename(res.get("name") or os.path.basename(archive), os.path.basename(archive) or f"resource-{index + 1}.bin") + info = zf.getinfo(archive) + with zf.open(archive) as src: + content = src.read() + digest = hashlib.sha256(content).hexdigest() + new_url = find_duplicate_import_asset(base, info.file_size, digest) + reused = bool(new_url) + if not new_url: + os.makedirs(import_dir, exist_ok=True) + target = os.path.join(import_dir, base) + if os.path.exists(target): + stem, ext = os.path.splitext(base) + target = os.path.join(import_dir, f"{stem}_{uuid.uuid4().hex[:6]}{ext}") + with open(target, "wb") as dst: + dst.write(content) + rel = os.path.relpath(target, ASSETS_DIR).replace("\\", "/") + new_url = f"/assets/{rel}" + old_url = str(res.get("url") or "").strip() + if old_url: + resource_mapping[old_url] = new_url + resource_mapping[archive] = new_url + resource_mapping[f"./{archive}"] = new_url + resource_mapping[os.path.basename(archive)] = new_url + imported_resources.append({"from": old_url or archive, "to": new_url, "reused": reused}) + return canvas, resource_mapping, imported_resources + except HTTPException: + raise + except zipfile.BadZipFile as exc: + raise HTTPException(status_code=400, detail="无法读取画布压缩包") from exc + except Exception as exc: + raise HTTPException(status_code=400, detail=f"无法解析画布压缩包:{exc}") from exc + try: + return json.loads(raw.decode("utf-8-sig")), {}, [] + except Exception as exc: + raise HTTPException(status_code=400, detail=f"无法解析画布 JSON:{exc}") from exc + + +def find_duplicate_import_asset(name: str, size: int, digest: str = ""): + safe_name = os.path.basename(str(name or "")).strip() + if not safe_name or size < 0: + return "" + root = os.path.abspath(ASSETS_DIR) + if not os.path.isdir(root): + return "" + for dirpath, _, filenames in os.walk(root): + for filename in filenames: + if filename != safe_name: + continue + path = os.path.join(dirpath, filename) + try: + if os.path.getsize(path) != size: + continue + if digest: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + if h.hexdigest() != digest: + continue + rel = os.path.relpath(path, ASSETS_DIR).replace("\\", "/") + return f"/assets/{rel}" + except Exception: + continue + return "" + def load_canvas(canvas_id): path = canvas_path(canvas_id) if not os.path.exists(path): @@ -13826,6 +13962,41 @@ async def trashed_canvases(): async def create_canvas(payload: CanvasCreateRequest): return {"canvas": new_canvas(payload.title, payload.icon, payload.kind, payload.project, payload.board_x, payload.board_y)} + +@app.post("/api/canvases/import") +async def import_canvas( + file: UploadFile = File(...), + project: str = Form(""), + board_x: str = Form(""), + board_y: str = Form(""), +): + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="文件为空") + def maybe_float(value): + text = str(value or "").strip() + if not text: + return None + try: + return float(text) + except Exception: + return None + source, resource_mapping, imported_resources = read_canvas_import_archive(raw, file.filename or "") + canvas = imported_canvas_payload( + source, + project=project, + board_x=maybe_float(board_x), + board_y=maybe_float(board_y), + resource_mapping=resource_mapping, + ) + return { + "canvas": canvas_record(canvas), + "id": canvas.get("id"), + "resource_count": sum(1 for item in imported_resources if not item.get("reused")), + "reused_resource_count": sum(1 for item in imported_resources if item.get("reused")), + "resource_map": resource_mapping, + } + @app.get("/api/canvases/{canvas_id}/meta") async def get_canvas_meta(canvas_id: str): canvas = load_canvas(canvas_id) diff --git a/static/canvas-list.html b/static/canvas-list.html index b863daf03..901039fcc 100644 --- a/static/canvas-list.html +++ b/static/canvas-list.html @@ -21,7 +21,7 @@ - + @@ -57,7 +57,9 @@ 0
+ + @@ -67,6 +69,19 @@
+
+
+
+ +
+
导入画布
+
准备上传...
+
+ 0% +
+
+
+
暂无画布
@@ -90,6 +105,6 @@
- + diff --git a/static/css/canvas-list.css b/static/css/canvas-list.css index 564944148..d01341a10 100644 --- a/static/css/canvas-list.css +++ b/static/css/canvas-list.css @@ -126,8 +126,10 @@ html[data-studio-scale="off"].studio-scale-managed .workspace { .ws-board-name { font-size:17px; font-weight:850; letter-spacing:0; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:40vw; } .ws-board-count { min-width:20px; height:20px; padding:0 8px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background:var(--soft-2); color:var(--muted); font-size:11px; font-weight:800; } .ws-topbar-right { display:flex; align-items:center; gap:8px; flex:0 0 auto; } -.ws-icon-btn { width:36px; height:36px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--muted); background:var(--card-solid); border:1px solid var(--line); cursor:pointer; transition:all .15s var(--ease); } +.ws-icon-btn { width:36px; min-width:36px; height:36px; flex:0 0 36px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--muted); background:var(--card-solid); border:1px solid var(--line); cursor:pointer; transition:all .15s var(--ease); } .ws-icon-btn:hover { color:var(--text); border-color:var(--line-2); background:var(--soft); box-shadow:0 6px 14px var(--shadow); } +.ws-icon-btn:disabled { opacity:.58; cursor:default; transform:none; box-shadow:none; } +#importCanvasBtn { display:flex !important; visibility:visible !important; } .ws-primary-btn { height:36px; padding:0 14px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; gap:7px; background:var(--strong); color:var(--strong-text); border:none; cursor:pointer; font-size:12px; font-weight:850; box-shadow:0 8px 18px var(--shadow); } .ws-primary-btn:hover { transform:translateY(-1px); box-shadow:0 12px 24px var(--shadow-strong); } /* 粘贴到此项目:剪切后才出现的强调按钮,固定高对比色避免主题变量冲淡文字。 */ @@ -140,8 +142,25 @@ html[data-studio-scale="off"].studio-scale-managed .workspace { .ws-board { position:relative; flex:1; min-height:0; overflow:hidden; background:var(--page); background-image:linear-gradient(rgba(100,116,139,.045) 1px, transparent 1px), linear-gradient(90deg, rgba(100,116,139,.045) 1px, transparent 1px), radial-gradient(var(--grid) 1px, transparent 1px); background-size:120px 120px, 120px 120px, 24px 24px; cursor:grab; user-select:none; } .ws-board.panning { cursor:grabbing; } +.ws-board.import-dragging::after { content:""; position:absolute; inset:18px; z-index:34; border:2px dashed var(--accent); border-radius:8px; background:rgba(37,99,235,.08); box-shadow:inset 0 0 0 1px rgba(255,255,255,.18); pointer-events:none; } .ws-board-world { position:absolute; left:0; top:0; transform-origin:0 0; will-change:transform; } + +.ws-import-progress { position:absolute; inset:0; z-index:35; display:flex; align-items:center; justify-content:center; padding:20px; background:rgba(246,247,249,.42); opacity:0; pointer-events:none; transition:opacity .18s var(--ease); } +.ws-import-progress.open { opacity:1; pointer-events:auto; } +.ws-import-progress-panel { width:min(420px, calc(100vw - 56px)); padding:14px; border-radius:8px; background:var(--card-solid); border:1px solid var(--line); box-shadow:0 20px 50px var(--shadow-strong); } +.ws-import-progress-head { display:flex; align-items:center; gap:10px; min-width:0; } +.ws-import-progress-icon { width:34px; height:34px; flex:0 0 34px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--accent); background:var(--accent-soft); border:1px solid var(--accent-line); } +.ws-import-progress-title { font-size:13px; line-height:1.2; font-weight:850; color:var(--text); } +.ws-import-progress-sub { margin-top:3px; font-size:11.5px; line-height:1.4; font-weight:650; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:260px; } +.ws-import-progress-pct { margin-left:auto; flex:0 0 auto; min-width:42px; text-align:right; font-size:12px; font-weight:850; color:var(--muted); } +.ws-import-progress-track { margin-top:12px; height:7px; overflow:hidden; border-radius:999px; background:var(--soft-2); border:1px solid var(--line); } +.ws-import-progress-bar { width:0%; height:100%; border-radius:999px; background:var(--accent); transition:width .18s var(--ease), background .18s var(--ease); } +.ws-import-progress.done .ws-import-progress-icon { color:var(--success); background:#ecfdf5; border-color:#a7f3d0; } +.ws-import-progress.done .ws-import-progress-bar { background:var(--success); } +.ws-import-progress.failed .ws-import-progress-icon { color:var(--danger); background:var(--danger-soft); border-color:#fecaca; } +.ws-import-progress.failed .ws-import-progress-bar { background:var(--danger); } + .ws-board-empty { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); display:flex; flex-direction:column; align-items:center; gap:12px; width:min(320px, calc(100% - 48px)); color:var(--faint); } .ws-board-empty.hidden { display:none; } .ws-board-empty-icon { width:58px; height:58px; border-radius:8px; display:flex; align-items:center; justify-content:center; background:var(--card-solid); border:1px solid var(--line); color:var(--faint); box-shadow:0 12px 28px var(--shadow); } @@ -271,6 +290,10 @@ html[data-studio-scale="off"].studio-scale-managed .workspace { .theme-dark .ws-pop-item.danger:hover { background:rgba(220,38,38,.18); } .theme-dark .ws-card-delete-no { background:var(--soft); } +.theme-dark .ws-import-progress { background:rgba(16,20,29,.46); } +.theme-dark .ws-import-progress.done .ws-import-progress-icon { background:rgba(5,150,105,.16); border-color:rgba(16,185,129,.34); } +.theme-dark .ws-import-progress.failed .ws-import-progress-icon { background:rgba(220,38,38,.16); border-color:rgba(248,113,113,.34); } + /* ===== Responsive ===== */ @media (max-width:760px) { .workspace { flex-direction:column; } diff --git a/static/js/canvas-list.js b/static/js/canvas-list.js index bfde2fc9f..23a278620 100644 --- a/static/js/canvas-list.js +++ b/static/js/canvas-list.js @@ -58,6 +58,13 @@ const newProjectInput = document.getElementById('newProjectInput'); const newProjectConfirm = document.getElementById('newProjectConfirm'); const newProjectCancel = document.getElementById('newProjectCancel'); const newCanvasBtn = document.getElementById('newCanvasBtn'); +let importCanvasBtn = document.getElementById('importCanvasBtn'); +const importCanvasInput = document.getElementById('importCanvasInput'); +const importProgress = document.getElementById('importProgress'); +const importProgressTitle = document.getElementById('importProgressTitle'); +const importProgressSub = document.getElementById('importProgressSub'); +const importProgressPct = document.getElementById('importProgressPct'); +const importProgressBar = document.getElementById('importProgressBar'); const boardRefreshBtn = document.getElementById('boardRefresh'); const boardResetViewBtn = document.getElementById('boardResetView'); const pasteCanvasBtn = document.getElementById('pasteCanvasBtn'); @@ -72,6 +79,7 @@ let currentProjectId = rememberedProjectId(); let pendingDeleteProjectId = null; let statusTimer = null; let clipboardCanvasId = null; // 剪切的画布(切到别的项目后粘贴) +let importInFlight = false; // board viewport (mirrors smart-canvas math) const viewport = { x: 0, y: 0, scale: 1 }; @@ -87,6 +95,44 @@ function setStatus(text){ statusTimer = setTimeout(() => statusEl.classList.remove('show'), 2200); } + +function ensureImportCanvasButton(){ + if(importCanvasBtn && document.body.contains(importCanvasBtn)) return importCanvasBtn; + const bar = document.querySelector('.ws-topbar-right'); + if(!bar) return null; + importCanvasBtn = document.createElement('button'); + importCanvasBtn.id = 'importCanvasBtn'; + importCanvasBtn.className = 'ws-icon-btn'; + importCanvasBtn.type = 'button'; + importCanvasBtn.title = L('导入画布','Import canvas'); + importCanvasBtn.setAttribute('aria-label', L('导入画布','Import canvas')); + importCanvasBtn.innerHTML = ''; + bar.insertBefore(importCanvasBtn, boardResetViewBtn || bar.firstChild); + refreshIcons(); + return importCanvasBtn; +} + +function isCanvasImportFile(file){ + if(!file) return false; + const name = String(file.name || '').toLowerCase(); + const type = String(file.type || '').toLowerCase(); + return name.endsWith('.json') || name.endsWith('.zip') || type.includes('json') || type.includes('zip'); +} + +function canvasImportFileFromTransfer(dataTransfer){ + const files = Array.from(dataTransfer?.files || []); + const file = files.find(isCanvasImportFile); + if(file) return file; + for(const item of Array.from(dataTransfer?.items || [])){ + if(item.kind && item.kind !== 'file') continue; + const maybe = typeof item.getAsFile === 'function' ? item.getAsFile() : null; + if(isCanvasImportFile(maybe)) return maybe; + const type = String(item.type || '').toLowerCase(); + if(type.includes('json') || type.includes('zip')) return {name:type.includes('zip') ? 'canvas.zip' : 'canvas.json', type}; + } + return null; +} + /* ===== Viewport math (mirrors smart-canvas.js) ===== */ function applyViewport(){ boardWorld.style.transform = `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`; @@ -106,6 +152,40 @@ function boardCenterWorld(){ y: (board.clientHeight / 2 - viewport.y) / viewport.scale }; } + +function rectsOverlap(a, b, gap = 18){ + return !(a.x + a.w + gap <= b.x || b.x + b.w + gap <= a.x || a.y + a.h + gap <= b.y || b.y + b.h + gap <= a.y); +} + +function findBlankBoardPosition(){ + const CARD_W = 248, CARD_H = 150, STEP_X = 276, STEP_Y = 176; + const center = boardCenterWorld(); + const existing = canvasesInProject(currentProjectId) + .filter(c => c.board_x != null && c.board_y != null) + .map(c => ({ x: Number(c.board_x) || 0, y: Number(c.board_y) || 0, w: CARD_W, h: CARD_H })); + const baseX = Math.round((center.x - CARD_W / 2) / STEP_X) * STEP_X; + const baseY = Math.round((center.y - CARD_H / 2) / STEP_Y) * STEP_Y; + const offsets = [[0,0], [1,0], [0,1], [1,1], [-1,0], [0,-1], [2,0], [0,2], [2,1], [1,2], [-1,1], [1,-1]]; + for(let ring = 0; ring <= 12; ring++){ + const ringOffsets = ring === 0 ? offsets : []; + if(ring > 0){ + for(let dx = -ring; dx <= ring; dx++){ + ringOffsets.push([dx, -ring], [dx, ring]); + } + for(let dy = -ring + 1; dy <= ring - 1; dy++){ + ringOffsets.push([-ring, dy], [ring, dy]); + } + } + for(const [dx, dy] of ringOffsets){ + const candidate = { x: baseX + dx * STEP_X, y: baseY + dy * STEP_Y, w: CARD_W, h: CARD_H }; + if(existing.every(rect => !rectsOverlap(candidate, rect))){ + return { x: candidate.x, y: candidate.y }; + } + } + } + return { x: baseX + STEP_X, y: baseY + STEP_Y }; +} + function resetView(){ const cards = Array.from(boardWorld.querySelectorAll('.ws-card')); if(!cards.length){ @@ -547,6 +627,108 @@ async function createCanvasOnBoard(title, kind, worldPt){ } catch(e){ console.error(e); setStatus(L('创建失败','Create failed')); } } + +function setImportProgress(percent, subText, state){ + if(!importProgress) return; + const value = Math.max(0, Math.min(100, Number(percent) || 0)); + importProgress.classList.add('open'); + importProgress.classList.toggle('done', state === 'done'); + importProgress.classList.toggle('failed', state === 'failed'); + if(importProgressTitle) importProgressTitle.textContent = state === 'failed' ? L('导入失败','Import failed') : L('导入画布','Import canvas'); + if(importProgressSub && subText) importProgressSub.textContent = subText; + if(importProgressPct) importProgressPct.textContent = `${Math.round(value)}%`; + if(importProgressBar) importProgressBar.style.width = `${value}%`; +} + +function closeImportProgress(delay = 650){ + if(!importProgress) return; + window.setTimeout(() => { + importProgress.classList.remove('open', 'done', 'failed'); + if(importProgressSub) importProgressSub.textContent = L('准备上传...','Preparing upload...'); + if(importProgressPct) importProgressPct.textContent = '0%'; + if(importProgressBar) importProgressBar.style.width = '0%'; + }, delay); +} + +function uploadCanvasImport(form, onProgress){ + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/canvases/import'); + xhr.onloadstart = () => onProgress?.(4, L('准备上传...','Preparing upload...')); + xhr.upload.onprogress = e => { + if(e.lengthComputable && e.total > 0){ + const pct = Math.min(88, Math.max(8, (e.loaded / e.total) * 88)); + onProgress?.(pct, L('正在上传画布文件...','Uploading canvas file...')); + } else { + onProgress?.(35, L('正在上传画布文件...','Uploading canvas file...')); + } + }; + xhr.upload.onload = () => onProgress?.(92, L('正在解析画布和资源...','Parsing canvas and assets...')); + xhr.onerror = () => reject(new Error(L('网络错误,导入失败','Network error, import failed'))); + xhr.onload = () => { + let data = {}; + try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } + catch(e){ return reject(new Error(L('导入响应解析失败','Import response parse failed'))); } + if(xhr.status < 200 || xhr.status >= 300){ + reject(new Error(data.detail || data.error || L('导入失败','Import failed'))); + return; + } + resolve(data); + }; + xhr.send(form); + }); +} + +async function importCanvasFile(file){ + if(!file) return; + if(importInFlight){ + setStatus(L('已有导入任务正在进行','Import already in progress')); + return; + } + importInFlight = true; + if(importCanvasBtn) importCanvasBtn.disabled = true; + closeCreateCard(); + closeCardMenu(); + const worldPt = findBlankBoardPosition(); + const form = new FormData(); + form.append('file', file); + form.append('project', currentProjectId || 'default'); + form.append('board_x', String(Math.round(worldPt.x))); + form.append('board_y', String(Math.round(worldPt.y))); + setImportProgress(4, L('准备上传...','Preparing upload...')); + setStatus(L('正在导入画布...','Importing canvas...')); + try { + const data = await uploadCanvasImport(form, setImportProgress); + const nc = data.canvas; + if(nc){ + if(nc.project == null) nc.project = currentProjectId; + if(nc.board_x == null) nc.board_x = Math.round(worldPt.x); + if(nc.board_y == null) nc.board_y = Math.round(worldPt.y); + canvases.unshift(nc); + renderBoard(); + renderProjects(); + } else { + await loadAll(); + } + const count = Number(data.resource_count || 0); + const reused = Number(data.reused_resource_count || 0); + const doneText = reused + ? L(`已导入画布,新增 ${count} 个资源,复用 ${reused} 个资源`, `Imported canvas, added ${count} assets, reused ${reused}`) + : (count ? L(`已导入画布和 ${count} 个资源`, `Imported canvas and ${count} assets`) : L('已导入画布','Imported canvas')); + setImportProgress(100, doneText, 'done'); + setStatus(doneText); + closeImportProgress(800); + } catch(e){ + console.error(e); + setImportProgress(100, e.message || L('导入失败','Import failed'), 'failed'); + setStatus(e.message || L('导入失败','Import failed')); + closeImportProgress(1400); + } finally { + importInFlight = false; + if(importCanvasBtn) importCanvasBtn.disabled = false; + } +} + /* ===== Card context menu (rename / delete / move) ===== */ function closeCardMenu(){ document.querySelector('.ws-card-pop')?.remove(); } function openCardMenu(canvasId, anchorBtn){ @@ -990,6 +1172,46 @@ board.addEventListener('dblclick', e => { }); newCanvasBtn.addEventListener('click', () => openCreateCard(boardCenterWorld())); + +ensureImportCanvasButton(); +document.addEventListener('click', e => { + if(e.target.closest?.('#importCanvasBtn')){ + e.preventDefault(); + ensureImportCanvasButton(); + importCanvasInput?.click(); + } +}); +importCanvasInput?.addEventListener('change', e => { + const file = e.target.files?.[0]; + e.target.value = ''; + importCanvasFile(file); +}); +board.addEventListener('dragenter', e => { + if(canvasImportFileFromTransfer(e.dataTransfer)){ + e.preventDefault(); + board.classList.add('import-dragging'); + } +}); +board.addEventListener('dragover', e => { + if(canvasImportFileFromTransfer(e.dataTransfer)){ + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + board.classList.add('import-dragging'); + } +}); +board.addEventListener('dragleave', e => { + if(e.target === board || !board.contains(e.relatedTarget)){ + board.classList.remove('import-dragging'); + } +}); +board.addEventListener('drop', e => { + const file = canvasImportFileFromTransfer(e.dataTransfer); + if(!file) return; + e.preventDefault(); + e.stopPropagation(); + board.classList.remove('import-dragging'); + importCanvasFile(file); +}); emptyCreateCanvasBtn?.addEventListener('mousedown', e => e.stopPropagation()); emptyCreateCanvasBtn?.addEventListener('click', e => { e.stopPropagation();