0.1.02思维导图全屏/lightrag修复/luckysheet修复

This commit is contained in:
liaibo
2026-01-06 05:21:53 +08:00
parent fb6832c30d
commit 9884ea0ea6
12 changed files with 570 additions and 199 deletions
+2 -5
View File
@@ -41,8 +41,5 @@ cankao/
**/test/
pw-tests/**
pw-tests/
wolai-frontend\public\documents
wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/index.md
wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/index.md
wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/mindmap.json
wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/mindmap.json
wolai-frontend/public/documents/
wolai-frontend/public/documents/**
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = "Stop"
# 说明:
# 1) LightRAG Server 启动时会在控制台打印 Emoji/彩色字符,Windows 默认编码(GBK)可能导致启动失败。
# 2) 本脚本强制使用 UTF-8,并确保存在一个到 supabase-db 的端口转发(host:15433 -> supabase-db:5432)。
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
$env:PYTHONLEGACYWINDOWSSTDIO = "1"
Set-Location -Path $PSScriptRoot
function Ensure-SupabaseDbForwarder {
$name = "supabase-db-forward"
$exists = (docker ps -a --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null
if (-not $exists) {
Write-Host "创建端口转发容器:$name15433 -> supabase-db:5432"
docker run -d --name $name --restart unless-stopped --network supabase_default -p 15433:5432 alpine/socat -v TCP-LISTEN:5432,fork,reuseaddr TCP:supabase-db:5432 | Out-Null
} else {
$running = (docker ps --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null
if (-not $running) {
Write-Host "启动端口转发容器:$name"
docker start $name | Out-Null
}
}
}
Ensure-SupabaseDbForwarder
Write-Host "启动 LightRAG Serverhttp://127.0.0.1:7777"
Write-Host "提示:API-Key 使用 X-API-Key: aimnote-local(可在 .env 中调整 LIGHTRAG_API_KEY"
& ".\.venv\Scripts\lightrag-server.exe"
+4 -5
View File
@@ -1,10 +1,12 @@
import os
from functools import lru_cache
from pydantic import BaseSettings, Field
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""FastAPI 配置,统一读取 .env 或系统环境变量"""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
app_name: str = Field("AIMNOTE Ingest Service", env="INGEST_APP_NAME")
environment: str = Field("development", env="INGEST_ENV")
@@ -12,16 +14,13 @@ class Settings(BaseSettings):
supabase_url: str = Field("http://127.0.0.1:54321", env="SUPABASE_URL")
supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
ollama_base_url: str = Field("http://127.0.0.1:11434/v1", env="OLLAMA_BASE_URL")
ollama_api_key: str = Field("ollama", env="OLLAMA_API_KEY")
embeddings_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL")
llm_model: str = Field("qwen3:32b", env="DEFAULT_LLM_MODEL")
rerank_model: str = Field("qwen3-reranker-4b", env="RERANK_MODEL")
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
@@ -1,42 +1,13 @@
import sys
from pathlib import Path
from app.core.config import get_settings
from app.models.job import IngestJob
from app.services.embeddings import EmbeddingGenerator
from app.services.job_store import InMemoryJobStore
from app.services.scheduler import SchedulerManager
from app.services.tasks import IngestWorker
from app.services.webhooks import LightRAGWebhook
ROOT = Path(__file__).resolve().parents[3]
sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src"))
from siyuan_ingest import SiYuanClient, SupabaseSync, SupabaseWriter # type: ignore # noqa: E402
settings = get_settings()
job_store = InMemoryJobStore()
scheduler = SchedulerManager()
siyuan_client = SiYuanClient()
webhook = LightRAGWebhook()
embedding_generator = EmbeddingGenerator(
api_base=settings.ollama_base_url,
api_key=settings.ollama_api_key,
model=settings.embeddings_model,
)
supabase_sync = SupabaseSync(
supabase_url=settings.supabase_url + "/rest/v1",
supabase_key=settings.supabase_key,
)
supabase_writer = SupabaseWriter(
supabase_url=settings.supabase_url + "/rest/v1",
supabase_key=settings.supabase_key,
)
worker = IngestWorker(
job_store=job_store,
siyuan_client=siyuan_client,
webhook=webhook,
supabase_sync=supabase_sync,
supabase_writer=supabase_writer,
embedding_generator=embedding_generator,
)
worker = IngestWorker(job_store=job_store, webhook=webhook)
+28 -68
View File
@@ -1,49 +1,26 @@
import asyncio
import contextlib
import logging
import sys
from pathlib import Path
from typing import Dict, List, Optional
from typing import List, Optional
from uuid import uuid4
from app.models.job import IngestJob
from app.services.embeddings import EmbeddingGenerator
from app.services.job_store import InMemoryJobStore
from app.services.webhooks import LightRAGWebhook
ROOT = Path(__file__).resolve().parents[3]
sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src"))
from siyuan_ingest import ( # type: ignore # noqa: E402
IngestState,
SiYuanClient,
SiYuanContentExtractor,
SupabaseSync,
SupabaseWriter,
chunk_blocks,
)
logger = logging.getLogger(__name__)
class IngestWorker:
"""处理 ingest 队列,串联 SiYuan → Supabase → LightRAG"""
"""处理 ingest 队列:将外部文本推送到 LightRAG
def __init__(
self,
job_store: InMemoryJobStore,
siyuan_client: SiYuanClient,
webhook: LightRAGWebhook,
supabase_sync: Optional[SupabaseSync] = None,
supabase_writer: Optional[SupabaseWriter] = None,
embedding_generator: Optional[EmbeddingGenerator] = None,
) -> None:
说明:历史版本依赖 `siyuan_ingest` 从思源笔记拉取块数据,但该包当前不在仓库内,
回档后会导致服务无法启动。此处改为“由调用方直接提供 blocks 文本列表”。
"""
def __init__(self, job_store: InMemoryJobStore, webhook: LightRAGWebhook) -> None:
self.job_store = job_store
self.siyuan_client = siyuan_client
self.extractor = SiYuanContentExtractor(siyuan_client)
self.webhook = webhook
self.supabase_sync = supabase_sync
self.supabase_writer = supabase_writer
self.embedding_generator = embedding_generator
self.queue: asyncio.Queue[IngestJob] = asyncio.Queue()
self._consumer_task: Optional[asyncio.Task[None]] = None
@@ -57,13 +34,19 @@ class IngestWorker:
with contextlib.suppress(asyncio.CancelledError):
await self._consumer_task
async def enqueue(self, notebook_id: Optional[str], doc_id: Optional[str], source: str, blocks: List[str]) -> IngestJob:
async def enqueue(
self,
notebook_id: Optional[str],
doc_id: Optional[str],
source: str,
blocks: List[str],
) -> IngestJob:
job = IngestJob(
id=str(uuid4()),
notebook_id=notebook_id,
doc_id=doc_id,
source=source,
blocks=blocks,
blocks=blocks or [],
)
await self.job_store.save(job)
await self.queue.put(job)
@@ -80,12 +63,19 @@ class IngestWorker:
try:
job.mark_running()
await self.job_store.save(job)
blocks = await self.extractor.list_blocks(job.notebook_id or "")
chunks = chunk_blocks(blocks)
logger.info("任务 %s 产生 %s 个 chunk", job.id, len(chunks))
await self._maybe_embed(chunks)
await self._persist_to_supabase(job, chunks)
await self.webhook.notify_ingest(job.doc_id, [c["block_id"] for c in chunks if c.get("block_id")])
texts = [b.strip() for b in job.blocks if b and b.strip()]
if not texts:
raise ValueError("blocks 为空:当前 ingest_service 仅支持由调用方提供文本列表")
# 约定:doc_id 存在时使用 doc:// 作为 file_source,便于后续统一引用
file_source = f"doc://{job.doc_id}" if job.doc_id else f"ingest://{job.id}"
merged_text = "\n\n".join(texts)
track_id = await self.webhook.ingest_text(file_source=file_source, text=merged_text)
job.payload["file_source"] = file_source
if track_id:
job.payload["lightrag_track_id"] = track_id
job.mark_done()
except Exception as exc: # noqa: BLE001
logger.exception("任务 %s 失败: %s", job.id, exc)
@@ -93,33 +83,3 @@ class IngestWorker:
finally:
await self.job_store.save(job)
async def _persist_to_supabase(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None:
await self._maybe_sync_state(job, chunks)
if not self.supabase_writer:
logger.debug("未配置 SupabaseWriter,跳过落库")
return
await self.supabase_writer.ingest_chunks(job.notebook_id, chunks)
async def _maybe_sync_state(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None:
if not self.supabase_sync:
return
if not chunks:
return
merged_checksum = chunks[-1]["checksum"]
state = await self.supabase_sync.fetch_state(job.notebook_id, job.doc_id)
if state and not state.should_ingest(merged_checksum):
logger.info("任务 %s 未发生变化,跳过写入 Supabase", job.id)
return
state = state or IngestState(notebook_id=job.notebook_id, doc_id=job.doc_id)
state.update(merged_checksum)
await self.supabase_sync.upsert_state(state)
async def _maybe_embed(self, chunks: List[Dict[str, str]]) -> None:
if not self.embedding_generator:
return
texts = [c.get("text") or "" for c in chunks]
if not texts:
return
embeddings = await self.embedding_generator.embed_texts(texts)
for chunk, embedding in zip(chunks, embeddings):
chunk["embedding"] = embedding
@@ -1,5 +1,5 @@
import logging
from typing import List, Optional
from typing import Optional
import httpx
@@ -9,18 +9,36 @@ logger = logging.getLogger(__name__)
class LightRAGWebhook:
"""封装与 LightRAG 的交互,默认调用 /ingest"""
"""封装与 LightRAG 的交互
说明:当前 LightRAG(>=1.4.x) 的“文本入库”接口为 `/documents/text` / `/documents/texts`
旧版项目里使用的 `/ingest` 已不存在(回档后代码仍在调用旧接口)。
"""
def __init__(self, base_url: Optional[str] = None):
settings = get_settings()
self.base_url = base_url or settings.lightrag_url.rstrip("/")
self.api_key = settings.lightrag_api_key
async def notify_ingest(self, doc_id: Optional[str], block_ids: List[str]) -> None:
if not block_ids:
logger.info("无可用 block 推送给 LightRAG")
return
payload = {"doc_id": doc_id, "block_ids": block_ids}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{self.base_url}/ingest", json=payload)
def _headers(self) -> dict:
headers: dict = {}
if self.api_key:
headers["X-API-Key"] = self.api_key
return headers
async def ingest_text(self, file_source: str, text: str) -> Optional[str]:
"""向 LightRAG 提交一段文本,触发后台索引。
返回 track_id(如果服务端返回),便于后续排查。
"""
if not text.strip():
logger.info("空文本,跳过推送到 LightRAGfile_source=%s", file_source)
return None
payload = {"text": text, "file_source": file_source}
async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client:
resp = await client.post(f"{self.base_url}/documents/text", json=payload)
resp.raise_for_status()
logger.info("LightRAG ingest 完成,返回 %s", resp.status_code)
data = resp.json()
track_id = data.get("track_id") if isinstance(data, dict) else None
logger.info("LightRAG 入库请求已提交(file_source=%s, track_id=%s", file_source, track_id)
return track_id
+4 -5
View File
@@ -1,21 +1,20 @@
from functools import lru_cache
from pydantic import BaseSettings, Field
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
app_name: str = Field("AIMNOTE RAG Gateway", env="RAG_GATEWAY_APP_NAME")
api_prefix: str = "/rag"
environment: str = Field("development", env="RAG_GATEWAY_ENV")
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
supabase_rest_url: str = Field("http://127.0.0.1:54321/rest/v1", env="SUPABASE_REST_URL")
supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
embedding_base_url: str = Field("http://127.0.0.1:11434/v1", env="EMBEDDING_BASE_URL")
embedding_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL")
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache()
def get_gateway_settings() -> Settings:
@@ -1,37 +1,54 @@
import logging
import sys
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, Optional
import httpx
from app.core.config import get_gateway_settings
ROOT = Path(__file__).resolve().parents[3]
sys.path.append(str(ROOT / "siyuan-rag-llm-main"))
from utils.rag.rag_knowledge_base import HybridRAGKnowledgeBase # type: ignore # noqa: E402
logger = logging.getLogger(__name__)
class RagService:
"""包装 HybridRAGKnowledgeBase,供路由调用"""
"""RAG 网关:直接调用 LightRAG 独立服务。
说明:历史版本依赖 `siyuan-rag-llm-main`(回档后目录缺失),导致服务无法启动。
当前实现改为通过 HTTP 调用 LightRAG 的官方 API/query、/query/data 等)。
"""
def __init__(self) -> None:
settings = get_gateway_settings()
self.kb = HybridRAGKnowledgeBase(
lightrag_url=settings.lightrag_url,
supabase_rest_url=settings.supabase_rest_url,
supabase_service_key=settings.supabase_key,
embedding_base_url=settings.embedding_base_url,
embedding_model=settings.embedding_model,
)
self.base_url = settings.lightrag_url.rstrip("/")
self.api_key = settings.lightrag_api_key
def _headers(self) -> dict:
headers: dict = {}
if self.api_key:
headers["X-API-Key"] = self.api_key
return headers
async def query(self, query: str, top_k: int = 8) -> Dict[str, Any]:
return await self.kb.query(query=query, top_k=top_k)
"""调用 LightRAG /query,返回生成回答与引用。"""
payload = {"query": query, "top_k": top_k}
async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client:
resp = await client.post("/query", json=payload)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
return {"response": str(data), "references": []}
return data
async def graph(self, query: str) -> Dict[str, Any]:
result = await self.kb.lightrag.graph(query=query)
await self.kb.sync_graph_metadata(result)
return result
async def graph(self, query: str, top_k: int = 60) -> Dict[str, Any]:
"""调用 LightRAG /query/data 获取结构化检索结果(实体/关系/分块/引用)。"""
payload = {"query": query, "top_k": top_k, "mode": "mix"}
async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client:
resp = await client.post("/query/data", json=payload)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {"status": "failure", "message": "invalid response", "data": data}
async def path(self, source: str, target: str) -> Dict[str, Any]:
return await self.kb.lightrag.path(source=source, target=target)
"""路径查询:当前 LightRAG API 未提供等价 /path 接口,先返回明确错误。"""
message = "当前 LightRAG API 未提供 /path 等价接口(rag_gateway:path 暂不可用)"
logger.warning("%s: source=%s target=%s", message, source, target)
return {"status": "not_supported", "message": message, "source": source, "target": target}
+1 -1
View File
@@ -44,4 +44,4 @@ next-env.d.ts
**/test/
pw-tests/**
pw-tests/
wolai-frontend\public\documents
wolai-frontend/public/documents/**
@@ -1,7 +1,15 @@
"use client";
import "simple-mind-map/dist/simpleMindMap.esm.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
@@ -230,6 +238,9 @@ const MindmapBlockView = ({
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
@@ -238,6 +249,12 @@ const MindmapBlockView = ({
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
const [showNoteModal, setShowNoteModal] = useState(false);
const [noteContent, setNoteContent] = useState("");
const [localFullscreen, setLocalFullscreen] = useState(false);
const effectiveFullscreen = fullscreen || localFullscreen;
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hotkeyScopeRef = useRef(false);
const recentNodeDblclickRef = useRef(false);
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
const instance = mm ?? mindmap;
@@ -254,6 +271,11 @@ const MindmapBlockView = ({
[block.props.docId, currentDocumentId],
);
useEffect(() => {
hasLocalEditsRef.current = false;
applyingRemoteRef.current = false;
}, [docId]);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
@@ -283,10 +305,19 @@ const MindmapBlockView = ({
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return;
initialDataRef.current = data;
if (mindmap) {
mindmap.setData(data);
mindmap.command.clearHistory();
applyingRemoteRef.current = true;
try {
mindmap.setData(data);
mindmap.command.clearHistory();
} finally {
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 0);
}
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
@@ -297,6 +328,220 @@ const MindmapBlockView = ({
};
}, [docId, mindmap]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
const onPointerDownCapture = (e: Event) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const target = e.target as Node | null;
hotkeyScopeRef.current = !!(target && wrapper.contains(target));
};
document.addEventListener("pointerdown", onPointerDownCapture, true);
document.addEventListener("mousedown", onPointerDownCapture, true);
return () => {
document.removeEventListener("pointerdown", onPointerDownCapture, true);
document.removeEventListener("mousedown", onPointerDownCapture, true);
};
}, []);
// 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突
useEffect(() => {
if (!mindmap) return;
const mark = () => {
recentNodeDblclickRef.current = true;
window.setTimeout(() => {
recentNodeDblclickRef.current = false;
}, 0);
};
mindmap.on?.("node_dblclick", mark);
return () => {
mindmap.off?.("node_dblclick", mark);
};
}, [mindmap]);
const exitLocalFullscreen = useCallback(() => {
setActiveSidebar(null);
if (fullscreen) return;
if (typeof document === "undefined") {
setLocalFullscreen(false);
return;
}
if (document.fullscreenElement) {
document
.exitFullscreen()
.catch(() => {
// ignore
})
.finally(() => setLocalFullscreen(false));
return;
}
setLocalFullscreen(false);
}, [fullscreen]);
const enterLocalFullscreen = useCallback(() => {
if (fullscreen) return;
setLocalFullscreen(true);
setActiveSidebar(null);
if (typeof document === "undefined") return;
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement) return;
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
try {
const target = document.documentElement as any;
const p = target?.requestFullscreen?.();
if (p && typeof p.catch === "function") {
p.catch(() => {
// ignore:失败则保持 Portal 伪全屏
});
}
} catch {
// ignore:失败则保持 Portal 伪全屏
}
}, [fullscreen]);
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
useEffect(() => {
if (!localFullscreen) return;
if (typeof document === "undefined") return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// 进入全屏后尽量把焦点放到思维导图容器上,保证快捷键立即生效
queueMicrotask(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
});
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (fullscreen) return; // 外部传入的 fullscreen 不在这里处理
e.preventDefault();
e.stopPropagation();
exitLocalFullscreen();
};
window.addEventListener("keydown", onKeyDown, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
document.body.style.overflow = prevOverflow;
};
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UIElectron/Web 都可用)
useEffect(() => {
if (typeof document === "undefined") return;
const onFsChange = () => {
const active = Boolean(document.fullscreenElement);
setFullscreenApiActive(active);
// 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面
if (!active && localFullscreen) {
setLocalFullscreen(false);
}
};
document.addEventListener("fullscreenchange", onFsChange);
return () => {
document.removeEventListener("fullscreenchange", onFsChange);
};
}, [localFullscreen]);
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
useLayoutEffect(() => {
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!hotkeyScopeRef.current) return;
const mm = mindmapRef.current;
if (!mm || !mindmapReadyRef.current) return;
const target = e.target as HTMLElement | null;
const editClasses = (mm as any)?.editNodeClassList as string[] | undefined;
const wrapper = wrapperRef.current;
const isInWrapper = Boolean(wrapper && target && wrapper.contains(target));
if (target && isInWrapper) {
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
// contenteditable 输入中不拦截:避免破坏节点文本编辑/粘贴
if ((target as any).isContentEditable) return;
const editableAncestor = target.closest?.('[contenteditable="true"]');
if (editableAncestor) return;
if (editClasses && target.classList) {
for (const cls of editClasses) {
if (cls && target.classList.contains(cls)) return;
}
}
}
const isMod = e.ctrlKey || e.metaKey;
const key = e.key;
const lower = key.toLowerCase();
const stop = () => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const pickTargetNode = (inst: MindMapInstance) => {
const renderer = inst.renderer as any;
const active: any[] = renderer?.activeNodeList ?? [];
const last: any[] = renderer?.lastActiveNodeList ?? [];
const root = renderer?.root ?? renderer?.renderTree?._node ?? null;
return (active && active[0]) || (last && last[0]) || root || null;
};
if (key === "Enter" && !e.shiftKey && !e.altKey && !isMod) {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const node = pickTargetNode(inst);
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_NODE", false, [node]);
return;
}
if (key === "Tab" && !e.shiftKey && !e.altKey && !isMod) {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const node = pickTargetNode(inst);
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
return;
}
if (isMod && lower === "c") {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const renderer = inst.renderer as any;
if (typeof renderer?.copy === "function") renderer.copy();
return;
}
if (isMod && lower === "v") {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const renderer = inst.renderer as any;
const copyData = renderer?.beingCopyData ?? null;
if (copyData) {
inst.execCommand?.("PASTE_NODE", copyData);
return;
}
if (typeof renderer?.paste === "function") renderer.paste();
}
};
window.addEventListener("keydown", onKeyDownCapture, true);
return () => {
window.removeEventListener("keydown", onKeyDownCapture, true);
};
}, []);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
@@ -328,7 +573,7 @@ const MindmapBlockView = ({
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
}, 800);
const initialSyncDone = useRef(false);
useEffect(() => {
@@ -559,6 +804,7 @@ const MindmapBlockView = ({
]),
}) as MindMapInstance;
createdInstance = instance;
mindmapRef.current = instance;
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
//render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
mindmapReadyRef.current = true;
@@ -629,7 +875,6 @@ const MindmapBlockView = ({
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
}
mindmapReadyRef.current = true;
console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0);
centerAndFit();
};
@@ -673,6 +918,9 @@ const MindmapBlockView = ({
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", (data: unknown) => {
if (!applyingRemoteRef.current) {
hasLocalEditsRef.current = true;
}
debouncedPersist(data);
});
const renderer = instance.renderer;
@@ -682,7 +930,6 @@ const MindmapBlockView = ({
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(rootNode, true);
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(rootNode);
console.log("[mindmap] init select root", instance.renderer?.activeNodeList?.length ?? 0);
setActiveNodes([rootNode]);
}
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
@@ -692,7 +939,6 @@ const MindmapBlockView = ({
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(root, true);
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(root);
console.log("[mindmap] delayed select root", instance.renderer?.activeNodeList?.length ?? 0);
setActiveNodes([root]);
}, 120);
@@ -704,6 +950,32 @@ const MindmapBlockView = ({
return () => {
destroyed = true;
try {
// 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑
if (createdInstance && typeof window !== "undefined") {
const data =
createdInstance.getData?.(true) ?? createdInstance.getData?.();
if (data) {
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
} catch {
// ignore
}
try {
editor?.updateBlock(block, { props: { ...block.props, data } });
} catch {
// ignore
}
if (docId) {
fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
}).catch(() => {
// ignore
});
}
}
}
createdInstance?.destroy();
} catch {
// ignore
@@ -716,10 +988,11 @@ const MindmapBlockView = ({
}
}
setMindmap(null);
mindmapRef.current = null;
mindmapReadyRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.id]);
}, [block.id, effectiveFullscreen]);
const getInstanceCandidate = () =>
mindmap ??
@@ -1463,16 +1736,36 @@ const MindmapBlockView = ({
</div>
) : null;
if (fullscreen) {
return (
<div className="relative h-screen w-screen overflow-hidden bg-white">
<div className="fixed left-1/2 top-4 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
if (effectiveFullscreen) {
const fullscreenView = (
<div
ref={wrapperRef}
data-testid="mindmap-fullscreen"
tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
>
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
<div className="text-sm font-medium text-gray-700">
{fullscreenApiActive ? "(全屏)" : ""}
</div>
<button
type="button"
title="退出全屏"
className="rounded p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={exitLocalFullscreen}
>
<span className="text-lg leading-none">×</span>
</button>
</div>
<div className="fixed left-1/2 top-14 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div className="relative h-full w-full pt-12">
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
contentEditable={false}
/>
@@ -1489,7 +1782,15 @@ const MindmapBlockView = ({
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
@@ -1503,19 +1804,53 @@ const MindmapBlockView = ({
</div>
</div>
);
if (typeof document === "undefined") return null;
return createPortal(fullscreenView, document.body);
}
return (
<div className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm">
<div
ref={wrapperRef}
data-testid="mindmap-embed"
tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
>
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div
data-testid="mindmap-stage"
className="relative h-[520px] w-full overflow-hidden bg-white"
onPointerDown={() => {
hotkeyScopeRef.current = true;
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onMouseDown={() => {
hotkeyScopeRef.current = true;
// 兼容:某些环境下 pointer 事件不触发
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onDoubleClick={() => {
if (recentNodeDblclickRef.current) return;
enterLocalFullscreen();
}}
>
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
contentEditable={false}
/>
@@ -1532,7 +1867,15 @@ const MindmapBlockView = ({
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
@@ -15,35 +15,64 @@ export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
className={`absolute top-1/2 z-30 flex -translate-y-1/2 transition-all duration-300 ${
activeSidebar ? "right-[300px]" : "right-0"
}`}
contentEditable={false}
onPointerDownCapture={(e) => e.stopPropagation()}
onMouseDownCapture={(e) => e.stopPropagation()}
>
<div
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md hover:w-6 hover:-left-6 transition-all"
onClick={() => setShow(!show)}
style={{ display: show ? "flex" : "none" }}
data-testid="mindmap-sidebar-collapse-handle"
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md transition-all hover:-left-6 hover:w-6"
onClick={(e) => {
e.stopPropagation();
setShow((prev) => {
const next = !prev;
if (!next) {
// 避免在 setShow 的 updater 内触发父组件 setState,导致 React 警告
queueMicrotask(() => onSelect(null));
}
return next;
});
}}
>
<ChevronRight className="h-3 w-3" />
<ChevronRight
className={`h-3 w-3 transition-transform ${show ? "" : "rotate-180"}`}
/>
</div>
<div className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg">
{sidebarTriggers
.filter((item) => item.visible !== false)
.map((item) => {
const target = item.target;
const isActive = activeSidebar === target;
return (
<button
key={item.value}
onClick={() => onSelect(isActive ? null : target)}
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
isActive ? "bg-blue-50 text-blue-600 font-medium" : ""
}`}
>
<i className={`iconfont ${item.iconClass} text-[18px] leading-none`} />
<span className="text-xs whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
{show ? (
<div
data-testid="mindmap-sidebar-trigger-panel"
className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg"
>
{sidebarTriggers
.filter((item) => item.visible !== false)
.map((item) => {
const target = item.target;
const isActive = activeSidebar === target;
return (
<button
key={item.value}
type="button"
contentEditable={false}
onPointerDown={(ev) => ev.stopPropagation()}
onMouseDown={(ev) => ev.stopPropagation()}
onClick={(ev) => {
ev.stopPropagation();
onSelect(isActive ? null : target);
}}
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
isActive ? "bg-blue-50 font-medium text-blue-600" : ""
}`}
>
<i
className={`iconfont ${item.iconClass} text-[18px] leading-none`}
/>
<span className="whitespace-nowrap text-xs">{item.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
};
+7 -5
View File
@@ -52,14 +52,16 @@ export interface OnlineTableBlockProps {
// Global declarations for Luckysheet (minimal definition)
declare global {
interface Window {
luckysheet: {
luckysheet?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
create: (options: any) => void;
destroy: (id: string) => void;
create?: (options: any) => void;
destroy?: (id: string) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getluckysheetfile: () => any;
getluckysheetfile?: () => any;
enterEditMode?: () => void;
getluckysheet_select_save?: () => unknown;
// 实际功能会通过动态加载的脚本注入
// 最小化声明以通过 TypeScript 检查
// 最小化声明以通过 TypeScript 检查(必须允许 undefined
};
}
}