From 9c35f54560af87f519815e31cd4285c3fd62addb Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 13 Sep 2026 21:52:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E5=AE=8C=E6=88=90=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=B5=84=E4=BA=A7=E5=AD=98=E5=82=A8=E4=B8=8E=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + backend/app/contracts.py | 9 ++ backend/app/database/migrations.py | 22 +++ backend/app/routes.py | 35 ++++- .../app/services/workspace_asset_service.py | 141 ++++++++++++++++++ backend/tests/test_workspace.py | 2 + backend/tests/test_workspace_assets.py | 71 +++++++++ frontend/src-tauri/Cargo.toml | 4 +- frontend/src-tauri/src/workspace_broker.rs | 126 ++++++++++++++++ .../editor/SourceMarkdownEditor.spec.ts | 13 ++ .../features/editor/SourceMarkdownEditor.vue | 50 ++++++- .../editor/VisualMarkdownEditor.spec.ts | 18 +++ .../features/editor/VisualMarkdownEditor.vue | 104 ++++++++++++- frontend/src/services/apiClient.ts | 4 +- frontend/src/services/workspaceAssets.spec.ts | 14 ++ frontend/src/services/workspaceService.ts | 46 ++++++ 16 files changed, 655 insertions(+), 10 deletions(-) create mode 100644 backend/app/services/workspace_asset_service.py create mode 100644 backend/tests/test_workspace_assets.py create mode 100644 frontend/src/services/workspaceAssets.spec.ts diff --git a/README.md b/README.md index 4527044..2065edd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ flowchart LR 凭据不会写入前端 `localStorage`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。 +### 工作区图片存储 + +在源码或所见即所得编辑器中粘贴、拖入或选择 PNG、JPEG、GIF、WebP 图片后,OpenNexus 会按内容哈希保存到当前 Vault 的 `attachments/<哈希前两位>/.<扩展名>`。Markdown 使用相对路径引用图片,因此笔记目录整体复制、导出或同步后仍可定位原图;单张图片上限为 5 MiB,相同内容只保存一份。 + +图片二进制不写入 SQLite。数据库中的 `workspace_assets` 保存路径、SHA-256、媒体类型、大小和原始文件名,`workspace_asset_links` 保存图片与笔记的引用关系。另一台设备收到 Vault 文件后,会在首次显示图片时校验路径哈希并重建本机元数据。 + ## 开发环境 | 工具 | 版本 | diff --git a/backend/app/contracts.py b/backend/app/contracts.py index e739ad3..50dc54a 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -81,6 +81,15 @@ class FolderDeleteRequest(Contract): path: str +class WorkspaceAsset(Contract): + asset_id: str + path: str + content_hash: str + media_type: str + size: int + original_name: str + + # 笔记与检索 class NoteBlock(Contract): block_id: str diff --git a/backend/app/database/migrations.py b/backend/app/database/migrations.py index 088384e..a708cfd 100644 --- a/backend/app/database/migrations.py +++ b/backend/app/database/migrations.py @@ -172,6 +172,28 @@ MIGRATIONS: list[str] = [ """ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""", """ALTER TABLE chat_messages ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]';""", """ALTER TABLE chat_messages ADD COLUMN context_captured INTEGER NOT NULL DEFAULT 0;""", + # v13:工作区图片本体保存在 Vault;数据库只保存可检索元数据和笔记引用关系。 + """ + CREATE TABLE IF NOT EXISTS workspace_assets ( + asset_id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + content_hash TEXT NOT NULL UNIQUE, + media_type TEXT NOT NULL, + size INTEGER NOT NULL CHECK(size >= 0), + original_name TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS workspace_asset_links ( + asset_id TEXT NOT NULL REFERENCES workspace_assets(asset_id) ON DELETE CASCADE, + note_id TEXT NOT NULL DEFAULT '', + note_path TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('paste', 'drop', 'upload', 'sync')), + created_at TEXT NOT NULL, + PRIMARY KEY(asset_id, note_id, note_path) + ); + CREATE INDEX IF NOT EXISTS idx_workspace_asset_links_note + ON workspace_asset_links(note_id, note_path); + """, ] diff --git a/backend/app/routes.py b/backend/app/routes.py index aec5a8e..6faa8a5 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -3,9 +3,10 @@ import json from collections.abc import AsyncIterator from contextlib import aclosing from datetime import datetime, timezone +from typing import Literal from uuid import uuid4 -from fastapi import APIRouter, Header, Query, Request +from fastapi import APIRouter, Header, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse from app.agent import AgentCapacityError, AgentRunNotFoundError @@ -106,6 +107,7 @@ from app.contracts import ( TranscriptionJob, TranscriptionRequest, WorkspaceEntry, + WorkspaceAsset, WorkspaceInfo, WorkspaceOpenRequest, WorkspaceSnapshot, @@ -134,6 +136,7 @@ from app.services import ( task_service, transcription_service, workspace_service, + workspace_asset_service, ) from app.services.attachment_service import attachment_path @@ -258,6 +261,36 @@ async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResp return await workspace_service.delete_folder(request.path) +@router.post("/workspace/assets", response_model=WorkspaceAsset, tags=["Workspace"]) +async def create_workspace_asset( + request: Request, + filename: str = Query(min_length=1, max_length=255), + note_id: str = Query(default="", max_length=200), + note_path: str = Query(min_length=1, max_length=2000), + source: Literal["paste", "drop", "upload"] = Query(default="upload"), +) -> WorkspaceAsset: + content = bytearray() + async for chunk in request.stream(): + content.extend(chunk) + if len(content) > workspace_asset_service.MAX_IMAGE_BYTES: + raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。") + result = workspace_asset_service.store( + bytes(content), original_name=filename, note_id=note_id, + note_path=note_path, source=source, + ) + return WorkspaceAsset(**result) + + +@router.get("/workspace/assets/content", tags=["Workspace"]) +async def get_workspace_asset_content( + path: str = Query(min_length=1, max_length=500), + note_id: str = Query(default="", max_length=200), + note_path: str = Query(default="", max_length=2000), +) -> Response: + data, media_type = workspace_asset_service.read(path, note_id=note_id, note_path=note_path) + return Response(data, media_type=media_type, headers={"Cache-Control": "private, max-age=31536000, immutable"}) + + # 笔记 @router.get("/notes", response_model=NoteListResponse, tags=["Notes"]) async def list_notes( diff --git a/backend/app/services/workspace_asset_service.py b/backend/app/services/workspace_asset_service.py new file mode 100644 index 0000000..d389961 --- /dev/null +++ b/backend/app/services/workspace_asset_service.py @@ -0,0 +1,141 @@ +"""工作区图片资产:原图归 Vault,SQLite 保存元数据与笔记引用。""" +from __future__ import annotations + +import base64 +import hashlib +import os +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from uuid import uuid4 + +from app import host_bridge +from app.config import get_settings +from app.database.db import connect_knowledge, transaction +from app.errors import ApiError +from app.services.vault_paths import resolve_in_vault + +MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +def _image_kind(data: bytes) -> tuple[str, str]: + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "png", "image/png" + if data.startswith(b"\xff\xd8\xff"): + return "jpg", "image/jpeg" + if data.startswith((b"GIF87a", b"GIF89a")): + return "gif", "image/gif" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp", "image/webp" + raise ApiError(415, "WORKSPACE_IMAGE_UNSUPPORTED", "仅支持 PNG、JPEG、GIF 和 WebP 图片。") + + +def _desktop() -> bool: + return get_settings().environment == "desktop" + + +def _vault_id() -> str: + return host_bridge.vault_id.get() or "default" + + +def _validate_asset_path(path: str) -> str: + normalized = PurePosixPath(path.replace("\\", "/")) + parts = normalized.parts + if normalized.is_absolute() or ".." in parts or len(parts) != 3 or parts[0] != "attachments": + raise ApiError(400, "INVALID_PATH", "图片路径不属于工作区附件目录。") + return normalized.as_posix() + + +def _write_web(path: str, data: bytes) -> None: + target = resolve_in_vault(path) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + if target.read_bytes() != data: + raise ApiError(409, "RESOURCE_CONFLICT", "附件路径已有不同内容。") + return + temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp") + try: + temporary.write_bytes(data) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def _write_desktop(path: str, data: bytes) -> None: + if host_bridge.active is None: + raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。") + try: + host_bridge.active.call( + "workspace.assets.write", vault_id=_vault_id(), path=path, + content_base64=base64.b64encode(data).decode("ascii"), operation_id=str(uuid4()), + ) + except RuntimeError as error: + raise ApiError(409 if str(error) == "REVISION_CONFLICT" else 503, + str(error), "写入工作区图片失败。") from None + + +def _record(*, digest: str, path: str, media_type: str, size: int, original_name: str, + note_id: str, note_path: str, source: str) -> None: + asset_id = f"asset_{digest}" + now = datetime.now(timezone.utc).isoformat() + conn = connect_knowledge() + try: + with transaction(conn): + conn.execute( + "INSERT OR IGNORE INTO workspace_assets(asset_id,path,content_hash,media_type,size,original_name,created_at) VALUES(?,?,?,?,?,?,?)", + (asset_id, path, digest, media_type, size, Path(original_name).name[:255], now), + ) + if note_path: + conn.execute( + "INSERT OR IGNORE INTO workspace_asset_links(asset_id,note_id,note_path,source,created_at) VALUES(?,?,?,?,?)", + (asset_id, note_id, note_path.replace("\\", "/").lstrip("/"), source, now), + ) + finally: + conn.close() + + +def store(data: bytes, *, original_name: str, note_id: str, note_path: str, source: str) -> dict: + if not data: + raise ApiError(400, "WORKSPACE_IMAGE_EMPTY", "图片内容为空。") + if len(data) > MAX_IMAGE_BYTES: + raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。") + if source not in {"paste", "drop", "upload"}: + raise ApiError(400, "WORKSPACE_IMAGE_SOURCE_INVALID", "图片来源无效。") + extension, media_type = _image_kind(data) + digest = hashlib.sha256(data).hexdigest() + asset_id = f"asset_{digest}" + path = f"attachments/{digest[:2]}/{digest}.{extension}" + (_write_desktop if _desktop() else _write_web)(path, data) + + _record(digest=digest, path=path, media_type=media_type, size=len(data), + original_name=Path(original_name).name or f"image.{extension}", note_id=note_id, + note_path=note_path, source=source) + return {"asset_id": asset_id, "path": path, "content_hash": digest, + "media_type": media_type, "size": len(data), "original_name": Path(original_name).name} + + +def read(path: str, *, note_id: str = "", note_path: str = "") -> tuple[bytes, str]: + path = _validate_asset_path(path) + if _desktop(): + if host_bridge.active is None: + raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。") + try: + result = host_bridge.active.call("workspace.assets.read", vault_id=_vault_id(), path=path) + data = base64.b64decode(result["content_base64"], validate=True) + except (RuntimeError, KeyError, ValueError): + raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。") from None + else: + target = resolve_in_vault(path) + if not target.is_file() or target.is_symlink(): + raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。") + data = target.read_bytes() + if len(data) > MAX_IMAGE_BYTES: + raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片超过读取上限。") + _, media_type = _image_kind(data) + digest = hashlib.sha256(data).hexdigest() + expected = PurePosixPath(path).stem + if digest != expected: + raise ApiError(409, "WORKSPACE_IMAGE_HASH_MISMATCH", "工作区图片内容与路径哈希不一致。") + _record(digest=digest, path=path, media_type=media_type, size=len(data), + original_name=PurePosixPath(path).name, note_id=note_id, note_path=note_path, + source="sync") + return data, media_type diff --git a/backend/tests/test_workspace.py b/backend/tests/test_workspace.py index 4794917..8ee3b5c 100644 --- a/backend/tests/test_workspace.py +++ b/backend/tests/test_workspace.py @@ -112,6 +112,8 @@ def test_workspace_openapi_paths_are_published() -> None: "/api/workspace/folders", "/api/workspace/folders/rename", "/api/workspace/folders/delete", + "/api/workspace/assets", + "/api/workspace/assets/content", "/api/notes/{note_id}/rename", } <= paths.keys() diff --git a/backend/tests/test_workspace_assets.py b/backend/tests/test_workspace_assets.py new file mode 100644 index 0000000..eeed822 --- /dev/null +++ b/backend/tests/test_workspace_assets.py @@ -0,0 +1,71 @@ +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.database.db import connect +from app.main import app + + +PNG = b"\x89PNG\r\n\x1a\n" + b"fixture-image" + + +def test_workspace_image_is_content_addressed_and_linked() -> None: + with TestClient(app) as client: + response = client.post( + "/api/workspace/assets", + params={"filename": "截图.png", "note_id": "note-1", "note_path": "课程/笔记.md", "source": "paste"}, + content=PNG, + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 200 + asset = response.json() + target = get_settings().vault_path / asset["path"] + assert target.read_bytes() == PNG + assert asset["path"].startswith("attachments/") + + content = client.get("/api/workspace/assets/content", params={"path": asset["path"]}) + assert content.status_code == 200 + assert content.content == PNG + assert content.headers["content-type"] == "image/png" + + duplicate = client.post( + "/api/workspace/assets", + params={"filename": "same.png", "note_id": "note-2", "note_path": "另一篇.md", "source": "upload"}, + content=PNG, + ) + assert duplicate.json()["asset_id"] == asset["asset_id"] + conn = connect() + try: + assert conn.execute("SELECT count(*) FROM workspace_assets").fetchone()[0] == 1 + assert conn.execute("SELECT count(*) FROM workspace_asset_links").fetchone()[0] == 2 + finally: + conn.close() + + # 模拟另一台设备只同步 Vault 文件;读取时会重建本机派生元数据。 + conn = connect() + try: + conn.execute("DELETE FROM workspace_asset_links") + conn.execute("DELETE FROM workspace_assets") + finally: + conn.close() + restored = client.get( + "/api/workspace/assets/content", + params={"path": asset["path"], "note_id": "synced-note", "note_path": "同步/笔记.md"}, + ) + assert restored.status_code == 200 + conn = connect() + try: + assert conn.execute("SELECT source FROM workspace_asset_links").fetchone()[0] == "sync" + finally: + conn.close() + + +def test_workspace_image_rejects_unknown_content_and_traversal() -> None: + with TestClient(app) as client: + unsupported = client.post( + "/api/workspace/assets", + params={"filename": "fake.png", "note_path": "笔记.md", "source": "upload"}, + content=b"not an image", + ) + assert unsupported.status_code == 415 + traversal = client.get("/api/workspace/assets/content", params={"path": "../secret.png"}) + assert traversal.status_code == 400 diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 7203cdf..0ba18fc 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ required-features = ["desktop"] [features] default = [] -desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64", "dep:tokio"] +desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:tokio"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -27,7 +27,7 @@ fs2 = "0.4" tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } -base64 = { version = "0.22", optional = true } +base64 = "0.22" tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true } hmac = { version = "0.12", default-features = false } rand = { version = "0.8", default-features = false, features = ["getrandom"] } diff --git a/frontend/src-tauri/src/workspace_broker.rs b/frontend/src-tauri/src/workspace_broker.rs index 8a8e8ad..9400357 100644 --- a/frontend/src-tauri/src/workspace_broker.rs +++ b/frontend/src-tauri/src/workspace_broker.rs @@ -1,5 +1,6 @@ //! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。 use crate::workspace::Workspace; +use base64::{engine::general_purpose::STANDARD, Engine}; use serde::Deserialize; use serde_json::{json, Value}; @@ -27,6 +28,47 @@ struct Write { } #[derive(Deserialize)] #[serde(deny_unknown_fields)] +struct AssetWrite { + vault_id: String, + path: String, + content_base64: String, + operation_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AssetRead { + vault_id: String, + path: String, +} + +const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024; + +fn valid_asset_path(path: &str) -> bool { + let normalized = path.replace('\\', "/"); + let parts: Vec<_> = normalized.split('/').collect(); + parts.len() == 3 + && parts[0] == "attachments" + && parts[1].len() == 2 + && !parts + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + && ["png", "jpg", "gif", "webp"] + .iter() + .any(|suffix| normalized.ends_with(&format!(".{suffix}"))) +} + +fn valid_image_bytes(path: &str, bytes: &[u8]) -> bool { + let extension = path.rsplit('.').next().unwrap_or(""); + match extension { + "png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "jpg" => bytes.starts_with(b"\xff\xd8\xff"), + "gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "webp" => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP", + _ => false, + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct Operation { vault_id: String, operation_id: String, @@ -253,6 +295,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { .map_err(|e| e.code)?; Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry})) } + "workspace.assets.write" => { + let p: AssetWrite = decode(params)?; + bound(ws, &p.vault_id)?; + if !valid_asset_path(&p.path) { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let bytes = STANDARD + .decode(&p.content_base64) + .map_err(|_| "WORKSPACE_REQUEST_INVALID")?; + if bytes.is_empty() + || bytes.len() > MAX_IMAGE_BYTES + || !valid_image_bytes(&p.path, &bytes) + || crate::workspace::hash(&bytes) + != p.path + .split('/') + .last() + .unwrap_or("") + .split('.') + .next() + .unwrap_or("") + { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let target = ws.resolve(&p.path).map_err(|e| e.code)?; + if target.exists() { + let existing = std::fs::read(target).map_err(|_| "FILESYSTEM_ERROR")?; + if existing != bytes { + return Err("REVISION_CONFLICT".into()); + } + return Ok( + json!({"path":p.path,"hash":crate::workspace::hash(&bytes),"size":bytes.len()}), + ); + } + let entry = ws + .write_operation(&p.path, "", &bytes, "local", &p.operation_id) + .map_err(|e| e.code)?; + Ok(json!({"path":entry.path,"hash":entry.hash,"size":bytes.len()})) + } + "workspace.assets.read" => { + let p: AssetRead = decode(params)?; + bound(ws, &p.vault_id)?; + if !valid_asset_path(&p.path) { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let bytes = std::fs::read(ws.resolve(&p.path).map_err(|e| e.code)?) + .map_err(|_| "FILE_NOT_FOUND")?; + if bytes.len() > MAX_IMAGE_BYTES { + return Err("CORE_NOTE_TOO_LARGE".into()); + } + Ok(json!({"content_base64":STANDARD.encode(bytes)})) + } "workspace.operation" => { let p: Operation = decode(params)?; bound(ws, &p.vault_id)?; @@ -285,6 +378,39 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { mod tests { use super::*; #[test] + fn workspace_images_are_binary_content_addressed_and_vault_bound() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let bytes = b"\x89PNG\r\n\x1a\nfixture"; + let digest = crate::workspace::hash(bytes); + let path = format!("attachments/{}/{}.png", &digest[..2], digest); + let write = json!({"rpc":"workspace.assets.write","params":{ + "vault_id":ws.vault_id,"path":path,"content_base64":STANDARD.encode(bytes), + "operation_id":uuid::Uuid::new_v4().to_string() + }}); + let stored = dispatch(&mut ws, &write).unwrap(); + assert_eq!(stored["hash"], digest); + assert_eq!(dispatch(&mut ws, &write).unwrap()["hash"], digest); + let read = + json!({"rpc":"workspace.assets.read","params":{"vault_id":ws.vault_id,"path":path}}); + assert_eq!( + STANDARD + .decode( + dispatch(&mut ws, &read).unwrap()["content_base64"] + .as_str() + .unwrap() + ) + .unwrap(), + bytes + ); + let mut denied = write; + denied["params"]["vault_id"] = json!("other-vault"); + assert_eq!( + dispatch(&mut ws, &denied).unwrap_err(), + "VAULT_PERMISSION_CHANGED" + ); + } + #[test] fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); diff --git a/frontend/src/features/editor/SourceMarkdownEditor.spec.ts b/frontend/src/features/editor/SourceMarkdownEditor.spec.ts index 18b9099..99732aa 100644 --- a/frontend/src/features/editor/SourceMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/SourceMarkdownEditor.spec.ts @@ -47,3 +47,16 @@ it('冲突文档禁用命令,保持原始内容', async () => { expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' }) expect(store.content).toBe(original) }) + +it('选择图片后写入工作区并插入相对 Markdown 引用', async () => { + vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({ + asset_id: 'asset-fixture', path: 'attachments/aa/hash.png', content_hash: 'hash', + media_type: 'image/png', size: 12, original_name: '截图.png', reference: 'attachments/aa/hash.png', + }) + const input = wrapper!.get('input[type="file"]') + const file = new File(['png'], '截图.png', { type: 'image/png' }) + Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) + await input.trigger('change') + await vi.waitFor(() => expect(useEditorStore().content).toContain('![截图.png](attachments/aa/hash.png)')) + expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/fixture.md', 'note-fixture') +}) diff --git a/frontend/src/features/editor/SourceMarkdownEditor.vue b/frontend/src/features/editor/SourceMarkdownEditor.vue index 30969fa..f42ea36 100644 --- a/frontend/src/features/editor/SourceMarkdownEditor.vue +++ b/frontend/src/features/editor/SourceMarkdownEditor.vue @@ -9,10 +9,12 @@ import { useSettingsStore } from '@/stores/settings' import { registerEditorCommands } from '@/services/editorCommandService' import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties' import AppDialog from '@/components/common/AppDialog.vue' +import { storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService' const props = defineProps<{ initialContent: string }>() const editor = useEditorStore(), settings = useSettingsStore() const root = ref(null), error = ref('') +const imageInput = ref(null) const conflicts = ref([]), choices = ref({}) const proofing = new Compartment() let view: EditorView | undefined, dispose: (() => void) | undefined @@ -22,6 +24,31 @@ function attributes() { 'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' }) } function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) } +function imageFiles(list: FileList | null): File[] { + return [...(list ?? [])].filter(file => file.type.startsWith('image/')) +} +async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) { + if (!view || !available() || !files.length) return + const targetView = view, targetPath = editor.currentFilePath + const at = position ?? targetView.state.selection.main.from + const document = targetView.state.doc + error.value = '' + try { + const assets = [] + for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath!, editor.currentNoteId)) + if (view !== targetView || editor.currentFilePath !== targetPath || !available()) return + const markdown = assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n') + const insertion = targetView.state.doc.eq(document) ? Math.min(at, targetView.state.doc.length) : targetView.state.selection.main.from + targetView.dispatch({ changes: { from: insertion, insert: markdown } }) + targetView.focus() + } catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) } +} +function chooseImages() { imageInput.value?.click() } +function selectedImages(event: Event) { + const input = event.target as HTMLInputElement + void insertImages(imageFiles(input.files), 'upload') + input.value = '' +} function importProperties() { if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const } error.value = ''; choices.value = {} @@ -57,6 +84,20 @@ onMounted(() => { view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()), EditorView.lineWrapping, + EditorView.domEventHandlers({ + paste(event) { + const files = imageFiles(event.clipboardData?.files ?? null) + if (!files.length) return false + event.preventDefault(); void insertImages(files, 'paste'); return true + }, + drop(event, currentView) { + const files = imageFiles(event.dataTransfer?.files ?? null) + if (!files.length) return false + event.preventDefault() + const position = currentView.posAtCoords({ x: event.clientX, y: event.clientY }) ?? currentView.state.selection.main.from + void insertImages(files, 'drop', position); return true + }, + }), EditorView.updateListener.of(update => { if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) } }), @@ -82,7 +123,11 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })