884 lines
37 KiB
Python
884 lines
37 KiB
Python
# coding: utf-8
|
||
|
||
from loguru import logger
|
||
import os
|
||
from PyQt6.QtCore import Qt, QTimer, QUrl, QDir, QFileInfo
|
||
from PyQt6.QtGui import QPixmap
|
||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||
from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineContextMenuRequest
|
||
from qfluentwidgets import (
|
||
ImageLabel,
|
||
InfoBar,
|
||
InfoBarPosition,
|
||
IndeterminateProgressBar,
|
||
MessageBoxBase,
|
||
PushButton,
|
||
)
|
||
|
||
from app.core import (ImageLoaderThread, TextLoaderThread, UpdateFileContentThread)
|
||
|
||
|
||
class MonacoWebEnginePage(QWebEnginePage):
|
||
"""自定义WebEnginePage以处理右键菜单"""
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
def contextMenuEvent(self, event):
|
||
"""自定义右键菜单事件,允许基本的编辑操作"""
|
||
request = event.request()
|
||
# 可以在这里根据需要自定义右键菜单的行为
|
||
# 例如,只允许复制、粘贴等特定操作
|
||
# 目前我们让Monaco Editor自己处理右键菜单
|
||
pass
|
||
from app.core.api import miaoStarsBasicApi
|
||
|
||
from app.view.components.empty_card import EmptyCard
|
||
|
||
|
||
# 图片预览类
|
||
|
||
|
||
def createThumbnail(pixmap, max_size=200):
|
||
"""创建快速缩略图"""
|
||
if pixmap.isNull():
|
||
return pixmap
|
||
|
||
# 使用快速缩放算法
|
||
return pixmap.scaled(
|
||
max_size,
|
||
max_size,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.FastTransformation,
|
||
)
|
||
|
||
|
||
class OptimizedPreviewBox(MessageBoxBase):
|
||
def __init__(self, parent=None, url=None, fileId=None):
|
||
super().__init__(parent=parent)
|
||
self.fileId = fileId # 保存文件ID
|
||
self.url = url # 暂存原始URL,可能需要作为fallback
|
||
logger.info(f"初始化图片预览框,文件ID: {self.fileId}, 原始URL: {self.url}")
|
||
self.widget.setMinimumSize(500, 500)
|
||
|
||
self.original_pixmap = None
|
||
self.current_scale = 1.0
|
||
|
||
# 加载状态显示
|
||
self.loadingCard = EmptyCard(self)
|
||
self.loadingCard.load()
|
||
self.viewLayout.addWidget(self.loadingCard, 0, Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# 图片显示标签
|
||
self.previewLabel = ImageLabel(self)
|
||
self.previewLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.previewLabel.setScaledContents(False) # 重要:禁用自动缩放
|
||
self.viewLayout.addWidget(self.previewLabel, 0, Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# 延迟启动加载,避免阻塞UI初始化
|
||
from PyQt6.QtCore import QTimer
|
||
QTimer.singleShot(100, self._initImageLoading)
|
||
|
||
def _ensure_full_url(self, url):
|
||
"""确保URL是完整的,添加scheme和base URL(如果缺失)"""
|
||
if not url:
|
||
return url
|
||
|
||
# 检查URL是否已经包含scheme
|
||
if url.startswith(('http://', 'https://')):
|
||
return url
|
||
|
||
# 对于相对路径,使用API的base URL构建完整URL
|
||
# 移除可能的前导斜杠,避免重复
|
||
path = url.lstrip('/')
|
||
# 从MiaoStarsBasicApi获取base URL,但只使用到域名部分
|
||
base_url = miaoStarsBasicApi.basicApi.split('/api/v4')[0]
|
||
full_url = f"{base_url}/{path}"
|
||
logger.debug(f"将相对路径转换为完整URL: {url} -> {full_url}")
|
||
return full_url
|
||
|
||
def _initImageLoading(self):
|
||
"""初始化图片加载,优先使用getFileUrl方法获取临时URL"""
|
||
if self.fileId:
|
||
logger.info(f"使用文件URI {self.fileId} 获取临时预览URL")
|
||
try:
|
||
# 使用getFileUrl方法获取临时URL
|
||
from app.core.api import miaoStarsBasicApi
|
||
response = miaoStarsBasicApi.getFileUrl(self.fileId, redirect=False)
|
||
|
||
# 详细记录响应内容
|
||
logger.debug(f"getFileUrl响应: {response}")
|
||
|
||
# 处理getFileUrl的返回值,支持多种格式
|
||
if isinstance(response, str):
|
||
# 直接返回URL字符串的情况
|
||
preview_url = response.strip('` ')
|
||
logger.info(f"成功获取临时预览URL: {preview_url}")
|
||
self.url = preview_url
|
||
elif isinstance(response, dict):
|
||
# 检查是否有code字段表示错误
|
||
if response.get('code') == -1:
|
||
error_msg = response.get('msg', '获取临时URL失败')
|
||
logger.warning(f"获取临时URL失败: {error_msg},将使用原始URL作为fallback")
|
||
# 检查是否有urls字段(Cloudreve API返回格式)
|
||
elif 'urls' in response and isinstance(response['urls'], list) and len(response['urls']) > 0:
|
||
preview_url = response['urls'][0].get('url', '').strip('` ')
|
||
logger.info(f"成功获取临时预览URL: {preview_url}")
|
||
self.url = preview_url
|
||
# 检查是否有data字段(Cloudreve V4 API标准格式)
|
||
elif 'data' in response:
|
||
data = response['data']
|
||
if isinstance(data, dict):
|
||
# 如果data是字典,检查是否包含urls数组
|
||
if 'urls' in data and isinstance(data['urls'], list) and len(data['urls']) > 0:
|
||
preview_url = data['urls'][0].get('url', '').strip('` ')
|
||
logger.info(f"成功从data中获取临时预览URL: {preview_url}")
|
||
self.url = preview_url
|
||
else:
|
||
# 尝试将data直接转为字符串作为fallback
|
||
preview_url = str(data).strip('` ')
|
||
logger.warning(f"data字段不包含urls数组,使用原始data字符串: {preview_url}")
|
||
self.url = preview_url
|
||
else:
|
||
# data不是字典,直接转为字符串
|
||
preview_url = str(data).strip('` ')
|
||
logger.info(f"成功获取临时预览URL: {preview_url}")
|
||
self.url = preview_url
|
||
else:
|
||
error_msg = response.get('msg', '获取临时URL失败')
|
||
logger.warning(f"获取临时URL失败: {error_msg},将使用原始URL作为fallback")
|
||
if self.url:
|
||
self.url = self._ensure_full_url(self.url)
|
||
logger.info(f"使用处理后的fallback URL: {self.url}")
|
||
else:
|
||
logger.error("没有可用的URL,无法加载图片")
|
||
self.handleError("没有可用的图片URL")
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"获取预览URL时发生异常: {str(e)}")
|
||
# 尝试使用备用方式处理
|
||
if self.url:
|
||
self.url = self._ensure_full_url(self.url)
|
||
logger.info(f"异常后使用处理后的fallback URL: {self.url}")
|
||
else:
|
||
logger.error("异常后没有可用的URL,无法加载图片")
|
||
self.handleError(f"获取URL异常: {str(e)}")
|
||
return
|
||
else:
|
||
# 如果没有fileId,尝试处理原始URL
|
||
if self.url:
|
||
self.url = self._ensure_full_url(self.url)
|
||
logger.info(f"使用处理后的原始URL: {self.url}")
|
||
else:
|
||
logger.error("没有提供文件ID或URL,无法加载图片")
|
||
self.handleError("没有提供图片URL")
|
||
return
|
||
|
||
# 创建图片加载线程
|
||
self.imageLoaderThread = ImageLoaderThread(self.url)
|
||
self.imageLoaderThread.imageLoaded.connect(self.setPreviewImg)
|
||
self.imageLoaderThread.errorOccurred.connect(self.handleError)
|
||
self.imageLoaderThread.progressUpdated.connect(self.updateProgress)
|
||
|
||
# 开始加载
|
||
self.imageLoaderThread.start()
|
||
logger.info(f"开始加载图片: {self.url}")
|
||
|
||
# startLoading方法已被_initImageLoading替代
|
||
|
||
def updateProgress(self, progress):
|
||
"""更新加载进度"""
|
||
logger.debug(f"图片加载进度: {progress}%")
|
||
self.loadingCard.setText(f"正在加载图片... {progress}%")
|
||
|
||
def setPreviewImg(self, img: QPixmap):
|
||
"""设置预览图片"""
|
||
logger.info(f"图片加载成功,尺寸: {img.width()}x{img.height()}px")
|
||
self.loadingCard.hide()
|
||
self.original_pixmap = img
|
||
|
||
# 立即显示缩略图
|
||
thumbnail = createThumbnail(img)
|
||
self.previewLabel.setPixmap(thumbnail)
|
||
|
||
# 然后异步加载高质量版本
|
||
self.adjustImageSize()
|
||
|
||
def resizeEvent(self, event):
|
||
"""重写窗口大小改变事件"""
|
||
super().resizeEvent(event)
|
||
if self.original_pixmap and not self.original_pixmap.isNull():
|
||
# 使用定时器延迟调整,避免频繁调整
|
||
from PyQt6.QtCore import QTimer
|
||
|
||
QTimer.singleShot(50, self.adjustImageSize)
|
||
|
||
def adjustImageSize(self):
|
||
"""根据窗口大小动态调整图片尺寸"""
|
||
if not self.original_pixmap or self.original_pixmap.isNull():
|
||
return
|
||
|
||
# 获取可用显示区域大小
|
||
margin = 80
|
||
available_width = self.width() - margin * 2
|
||
available_height = self.height() - margin * 2
|
||
|
||
# 获取原始图片尺寸
|
||
original_width = self.original_pixmap.width()
|
||
original_height = self.original_pixmap.height()
|
||
|
||
# 计算缩放比例
|
||
width_ratio = available_width / original_width
|
||
height_ratio = available_height / original_height
|
||
scale_ratio = min(width_ratio, height_ratio, 1.0)
|
||
|
||
# 只在需要时重新缩放
|
||
if abs(scale_ratio - self.current_scale) > 0.05: # 变化超过5%才重新缩放
|
||
self.current_scale = scale_ratio
|
||
new_width = int(original_width * scale_ratio)
|
||
new_height = int(original_height * scale_ratio)
|
||
|
||
# 使用平滑缩放
|
||
scaled_pixmap = self.original_pixmap.scaled(
|
||
new_width,
|
||
new_height,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
|
||
self.previewLabel.setPixmap(scaled_pixmap)
|
||
|
||
def handleError(self, msg):
|
||
"""处理加载错误"""
|
||
logger.error(f"图片预览失败: {msg}")
|
||
self.loadingCard.error()
|
||
self.previewLabel.hide()
|
||
|
||
|
||
# 文本文档预览类
|
||
class PreviewTextBox(MessageBoxBase):
|
||
"""文本预览对话框"""
|
||
|
||
def __init__(self, parent=None, url=None, _id=None):
|
||
super().__init__(parent=parent)
|
||
# 处理URL,确保它是完整的URL
|
||
self.url = self._ensure_full_url(url)
|
||
logger.info(f"初始化文本预览框,URL: {self.url}, 文件ID: {_id}")
|
||
self.updateTxtThread = None
|
||
self.widget.setMinimumSize(600, 400)
|
||
self._id = _id
|
||
self.isChanged = False
|
||
|
||
# 设置编辑器HTML文件路径 - 修正为项目根目录的_internal文件夹
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
self.editor_index = os.path.join(project_root, "_internal/editor_main/index.html")
|
||
logger.info(f"编辑器HTML路径: {self.editor_index}")
|
||
|
||
# 创建占位符标签替代WebEngineView
|
||
from PyQt6.QtWidgets import QLabel
|
||
self.placeholderLabel = QLabel('''<div style='text-align:center; padding:20px;'>
|
||
<h3>文本编辑将在外部浏览器中打开</h3>
|
||
<p>请在浏览器中完成编辑后,点击"保存并返回"按钮</p>
|
||
<p>应用将自动检测并更新内容</p>
|
||
</div>''')
|
||
self.placeholderLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.placeholderLabel.setWordWrap(True)
|
||
self.viewLayout.addWidget(self.placeholderLabel)
|
||
|
||
# 初始化变量
|
||
self.editorContent = ""
|
||
self.tempFilePath = None
|
||
|
||
# 轮询定时器,用于检查浏览器是否保存了内容
|
||
self.pollingTimer = QTimer(self)
|
||
self.pollingTimer.setInterval(1000) # 每秒检查一次
|
||
self.pollingTimer.timeout.connect(self._checkBrowserContent)
|
||
|
||
# 加载状态显示
|
||
self.loadingCard = EmptyCard(self)
|
||
self.loadingCard.load()
|
||
self.viewLayout.addWidget(self.loadingCard, 0, Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# 确保在创建线程前使用处理后的URL
|
||
self.textLoaderThread = TextLoaderThread(self.url, fileId=_id)
|
||
self.textLoaderThread.textLoaded.connect(self.setTextContent)
|
||
self.textLoaderThread.errorOccurred.connect(self.handleError)
|
||
self.textLoaderThread.progressUpdated.connect(self.updateProgress)
|
||
|
||
self.yesButton.hide()
|
||
# 创建保存按钮
|
||
self.saveButton = PushButton("保存修改", self)
|
||
# 创建进度条
|
||
self.saveProgressBar = IndeterminateProgressBar(self)
|
||
self.saveProgressBar.setFixedHeight(4)
|
||
self.saveProgressBar.hide()
|
||
|
||
# 添加按钮和进度条到布局
|
||
self.buttonLayout.insertWidget(
|
||
0, self.saveButton, 1, Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
self.buttonLayout.insertWidget(
|
||
1, self.saveProgressBar, 1, Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
|
||
self.saveButton.setEnabled(False)
|
||
self.saveButton.clicked.connect(self.saveText)
|
||
self.cancelButton.setText("返回")
|
||
|
||
# 延迟启动加载,避免阻塞UI初始化
|
||
QTimer.singleShot(100, self.startLoading)
|
||
|
||
def saveText(self):
|
||
logger.info(f"保存文本文件修改,文件ID: {self._id}")
|
||
# 显示进度条并禁用按钮
|
||
self.saveProgressBar.show()
|
||
self.saveButton.setEnabled(False)
|
||
# 直接使用editorContent变量,因为我们已经切换到外部浏览器编辑模式
|
||
self._saveContent(self.editorContent)
|
||
|
||
def _successSave(self):
|
||
logger.info(f"文本文件保存成功,文件ID: {self._id}")
|
||
InfoBar.success(
|
||
"成功",
|
||
"修改保存成功",
|
||
Qt.Orientation.Horizontal,
|
||
True,
|
||
1000,
|
||
InfoBarPosition.TOP_RIGHT,
|
||
self.window(),
|
||
)
|
||
# 隐藏进度条
|
||
self.saveProgressBar.hide()
|
||
QTimer.singleShot(700, self.accept)
|
||
|
||
def _errorSave(self, msg):
|
||
logger.error(f"文本文件保存失败,文件ID: {self._id}, 错误: {msg}")
|
||
InfoBar.error(
|
||
"失败",
|
||
msg,
|
||
Qt.Orientation.Horizontal,
|
||
True,
|
||
1000,
|
||
InfoBarPosition.TOP_RIGHT,
|
||
self.window(),
|
||
)
|
||
# 隐藏进度条并重新启用按钮
|
||
self.saveProgressBar.hide()
|
||
self.saveButton.setEnabled(True)
|
||
|
||
|
||
|
||
def _ensure_full_url(self, url):
|
||
"""确保URL是完整的,添加scheme和base URL(如果缺失)"""
|
||
if not url:
|
||
return url
|
||
|
||
# 检查URL是否已经包含scheme
|
||
if url.startswith(('http://', 'https://')):
|
||
return url
|
||
|
||
# 对于相对路径,使用API的base URL构建完整URL
|
||
# 移除可能的前导斜杠,避免重复
|
||
path = url.lstrip('/')
|
||
# 从MiaoStarsBasicApi获取base URL,但只使用到域名部分
|
||
base_url = miaoStarsBasicApi.basicApi.split('/api/v4')[0]
|
||
full_url = f"{base_url}/{path}"
|
||
logger.debug(f"将相对路径转换为完整URL: {url} -> {full_url}")
|
||
return full_url
|
||
|
||
def startLoading(self):
|
||
"""开始加载文本"""
|
||
# 如果有fileId,尝试获取临时URL
|
||
if self._id:
|
||
logger.info(f"使用文件URI {self._id} 获取临时文本URL")
|
||
response = miaoStarsBasicApi.getFileUrl(self._id, redirect=False)
|
||
|
||
# 处理getFileUrl的返回值,支持多种格式
|
||
preview_url = None
|
||
if isinstance(response, dict):
|
||
# 检查是否是Cloudreve V4 API格式 (data.urls)
|
||
if response.get('code') == 0 and 'data' in response:
|
||
data = response['data']
|
||
# 格式1: data.urls数组
|
||
if isinstance(data, dict) and 'urls' in data and isinstance(data['urls'], list) and len(data['urls']) > 0:
|
||
preview_url = data['urls'][0].get('url', '').strip('` ')
|
||
# 格式2: data直接包含URL
|
||
elif isinstance(data, str):
|
||
preview_url = data.strip('` ')
|
||
# 检查是否直接包含urls字段
|
||
elif 'urls' in response and isinstance(response['urls'], list) and len(response['urls']) > 0:
|
||
preview_url = response['urls'][0].get('url', '').strip('` ')
|
||
|
||
# 如果成功获取到URL
|
||
if preview_url:
|
||
self.url = preview_url
|
||
logger.info(f"成功获取临时文本URL: {self.url}")
|
||
else:
|
||
error_msg = response.get('msg', '获取临时URL失败')
|
||
logger.warning(f"获取临时URL失败: {error_msg},将使用处理后的原始URL")
|
||
|
||
# 更新线程的URL和fileId
|
||
self.textLoaderThread = TextLoaderThread(self.url, fileId=self._id)
|
||
self.textLoaderThread.textLoaded.connect(self.setTextContent)
|
||
self.textLoaderThread.errorOccurred.connect(self.handleError)
|
||
self.textLoaderThread.progressUpdated.connect(self.updateProgress)
|
||
|
||
logger.info(f"开始加载文本文件: {self.url}")
|
||
self.textLoaderThread.start()
|
||
|
||
def updateProgress(self, progress):
|
||
"""更新加载进度"""
|
||
logger.debug(f"文本加载进度: {progress}%")
|
||
self.loadingCard.setText(f"正在加载文本... {progress}%")
|
||
|
||
def setTextContent(self, content):
|
||
"""设置文本内容并在外部浏览器中打开"""
|
||
# 检查内容是否已保存,如果已保存则不允许再次编辑
|
||
if hasattr(self, 'isContentSaved') and self.isContentSaved:
|
||
logger.warning("内容已保存,不允许再次编辑")
|
||
self.placeholderLabel.setText('''<div style='text-align:center; padding:20px;'>
|
||
<h3>已经保存</h3>
|
||
<p>该内容已经保存,不再允许编辑</p>
|
||
</div>''')
|
||
return
|
||
|
||
logger.info(f"文本文件加载成功,原始内容长度: {len(content)}字符")
|
||
|
||
self.loadingCard.hide()
|
||
self.saveButton.setEnabled(True)
|
||
|
||
# 保存原始内容
|
||
self.editorContent = content
|
||
|
||
# 检测语言类型
|
||
language = self._detect_language(content)
|
||
|
||
# 创建临时文件用于保存内容和语言信息
|
||
import tempfile
|
||
import base64
|
||
|
||
# 创建临时文件
|
||
fd, self.tempFilePath = tempfile.mkstemp(suffix='.json', text=True)
|
||
os.close(fd)
|
||
|
||
# 对内容进行Base64编码
|
||
encoded_content = base64.b64encode(content.encode('utf-8')).decode('ascii')
|
||
|
||
# 保存到临时文件
|
||
import json
|
||
with open(self.tempFilePath, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
'content': encoded_content,
|
||
'language': language,
|
||
'file_id': self._id,
|
||
'saved': False # 初始状态为未保存
|
||
}, f)
|
||
|
||
# 获取编辑器HTML文件的绝对路径
|
||
editor_abs_path = os.path.abspath(self.editor_index)
|
||
|
||
# 构建URL参数
|
||
params = {
|
||
'temp_file': self.tempFilePath,
|
||
'content': encoded_content,
|
||
'language': language
|
||
}
|
||
|
||
# 导入必要的模块来创建本地Web服务器
|
||
import http.server
|
||
import socketserver
|
||
import threading
|
||
import sys
|
||
|
||
# 创建本地Web服务器类
|
||
class SimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||
def log_message(self, format, *args):
|
||
# 禁用服务器日志输出
|
||
pass
|
||
|
||
# 获取_internal目录的路径
|
||
# 当前文件路径是app/view/widgets/preview_box.py,需要找到项目根目录
|
||
current_file = os.path.abspath(__file__)
|
||
# 从当前文件路径向上找项目根目录(跳过app目录)
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
|
||
internal_dir = os.path.join(project_root, "_internal")
|
||
logger.info(f"项目根目录: {project_root}")
|
||
logger.info(f"_internal目录: {internal_dir}")
|
||
|
||
# 启动本地Web服务器
|
||
port = 36852
|
||
handler = SimpleHTTPRequestHandler
|
||
|
||
# 创建一个允许地址重用的TCPServer类
|
||
class ReuseTCPServer(socketserver.TCPServer):
|
||
allow_reuse_address = True
|
||
|
||
# 自定义HTTP请求处理器,用于处理POST保存请求
|
||
class EditorHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||
# 保存对PreviewTextBox实例的引用
|
||
preview_box_instance = None
|
||
|
||
def do_POST(self):
|
||
"""处理POST请求,特别是保存请求"""
|
||
if self.path == '/save':
|
||
# 获取请求体长度
|
||
content_length = int(self.headers['Content-Length'])
|
||
# 读取请求体数据
|
||
post_data = self.rfile.read(content_length)
|
||
|
||
try:
|
||
# 解析JSON数据
|
||
import json
|
||
save_data = json.loads(post_data)
|
||
|
||
# 记录保存请求
|
||
logger.info("接收到来自浏览器的保存请求")
|
||
|
||
# 使用preview_box_instance处理保存的数据
|
||
if EditorHTTPRequestHandler.preview_box_instance:
|
||
EditorHTTPRequestHandler.preview_box_instance._processSavedContent(save_data)
|
||
|
||
# 返回成功响应
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'application/json')
|
||
self.end_headers()
|
||
self.wfile.write(json.dumps({'status': 'success'}).encode('utf-8'))
|
||
except Exception as e:
|
||
logger.error(f"处理保存请求失败: {e}")
|
||
# 返回错误响应
|
||
self.send_response(500)
|
||
self.send_header('Content-Type', 'application/json')
|
||
self.end_headers()
|
||
self.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))
|
||
else:
|
||
# 对于其他POST请求,返回404
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
|
||
def log_message(self, format, *args):
|
||
"""禁用日志消息,保持安静"""
|
||
return
|
||
|
||
# 尝试使用多个备用端口,避免端口被占用
|
||
max_attempts = 5
|
||
attempts = 0
|
||
while attempts < max_attempts:
|
||
try:
|
||
# 设置处理器的实例引用
|
||
EditorHTTPRequestHandler.preview_box_instance = self
|
||
# 使用自定义处理器创建服务器
|
||
self.httpd = ReuseTCPServer(("127.0.0.1", port), EditorHTTPRequestHandler)
|
||
logger.info(f"Web服务器成功在端口 {port} 启动")
|
||
break
|
||
except OSError as e:
|
||
attempts += 1
|
||
logger.warning(f"端口 {port} 被占用,尝试使用备用端口")
|
||
port += 1 # 使用下一个端口
|
||
if attempts >= max_attempts:
|
||
logger.error(f"无法找到可用端口,最后一个错误: {e}")
|
||
raise
|
||
|
||
# 切换工作目录到_internal目录
|
||
if os.path.exists(internal_dir):
|
||
os.chdir(internal_dir)
|
||
logger.info(f"切换Web服务器工作目录到: {internal_dir}")
|
||
else:
|
||
logger.error(f"_internal目录不存在: {internal_dir}")
|
||
# 如果目录不存在,保持在当前目录运行服务器
|
||
|
||
# 在后台线程中运行服务器
|
||
self.server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||
self.server_thread.start()
|
||
|
||
# 构建基于HTTP的URL
|
||
from urllib.parse import urlencode
|
||
from PyQt6.QtGui import QDesktopServices
|
||
# 根据目录是否存在来调整HTML路径
|
||
if os.path.exists(internal_dir):
|
||
# 如果在_internal目录下运行服务器,使用相对路径
|
||
html_path = "editor_main/index.html"
|
||
else:
|
||
# 如果不在_internal目录下,使用完整路径
|
||
html_path = "_internal/editor_main/index.html"
|
||
url = f'http://127.0.0.1:{port}/{html_path}?{urlencode(params)}'
|
||
|
||
# 使用系统默认浏览器打开URL
|
||
logger.info(f"在外部浏览器中打开编辑器(HTTP): {url}")
|
||
QDesktopServices.openUrl(QUrl(url))
|
||
|
||
# 显示保存提醒
|
||
self.placeholderLabel.setText('''<div style='text-align:center; padding:20px;'>
|
||
<h3>文本编辑已在外部浏览器中打开</h3>
|
||
<p><strong>重要提示:</strong>完成编辑后,请点击浏览器中的"保存并返回"按钮</p>
|
||
<p>应用正在自动检测...</p>
|
||
</div>''')
|
||
|
||
# 启动轮询检查
|
||
self.pollingTimer.start()
|
||
|
||
def _checkBrowserContent(self):
|
||
"""检查浏览器是否已保存内容"""
|
||
# 首先尝试从localStorage读取
|
||
try:
|
||
import json
|
||
import os
|
||
import base64
|
||
|
||
# 尝试获取localStorage数据
|
||
# 在Windows上,localStorage通常存储在用户的AppData目录中
|
||
# 由于直接访问localStorage有困难,我们使用一个更可靠的方法:
|
||
# 1. 首先检查临时文件
|
||
if self.tempFilePath and os.path.exists(self.tempFilePath):
|
||
try:
|
||
with open(self.tempFilePath, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
|
||
if data.get('saved', False):
|
||
logger.info("检测到浏览器已保存内容(通过临时文件)")
|
||
self._processSavedContent(data)
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"读取临时文件失败: {e}")
|
||
|
||
# 2. 作为备用方案,检查是否有特定的保存文件
|
||
# 这个文件可以由浏览器通过特定的方法创建
|
||
import tempfile
|
||
app_data_dir = os.path.join(tempfile.gettempdir(), 'LeonPan')
|
||
os.makedirs(app_data_dir, exist_ok=True)
|
||
save_file_path = os.path.join(app_data_dir, 'editor_content.json')
|
||
|
||
if os.path.exists(save_file_path):
|
||
try:
|
||
with open(save_file_path, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
|
||
if data.get('saved', False):
|
||
logger.info("检测到浏览器已保存内容(通过备用文件)")
|
||
self._processSavedContent(data)
|
||
# 删除备用文件
|
||
os.unlink(save_file_path)
|
||
except Exception as e:
|
||
logger.error(f"读取备用保存文件失败: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"检查浏览器内容时出错: {e}")
|
||
|
||
def _processSavedContent(self, data):
|
||
"""处理已保存的内容"""
|
||
# 停止轮询
|
||
self.pollingTimer.stop()
|
||
|
||
# 更新内容
|
||
import base64
|
||
encoded_content = data.get('content', '')
|
||
try:
|
||
self.editorContent = base64.b64decode(encoded_content).decode('utf-8')
|
||
except Exception as e:
|
||
logger.error(f"解码内容失败: {e}")
|
||
return
|
||
|
||
# 标记内容已保存
|
||
self.isContentSaved = True
|
||
|
||
# 更新占位符文本,显示已保存信息
|
||
self.placeholderLabel.setText('''<div style='text-align:center; padding:20px;'>
|
||
<h3>已经保存</h3>
|
||
<p>内容已从浏览器同步到应用并已保存</p>
|
||
<p>该内容将不再允许编辑</p>
|
||
</div>''')
|
||
|
||
# 禁用保存按钮
|
||
self.saveButton.setEnabled(False)
|
||
|
||
# 清理临时文件
|
||
if self.tempFilePath and os.path.exists(self.tempFilePath):
|
||
try:
|
||
os.unlink(self.tempFilePath)
|
||
self.tempFilePath = None
|
||
except Exception as e:
|
||
logger.error(f"删除临时文件失败: {e}")
|
||
|
||
|
||
|
||
def handleError(self, error_msg):
|
||
"""处理加载错误"""
|
||
logger.error(f"文本预览失败,URL: {self.url}, 错误: {error_msg}")
|
||
self.loadingCard.error()
|
||
|
||
def _initMonacoEditor(self, content):
|
||
"""初始化Monaco Editor并加载内容"""
|
||
# 获取编辑器文件的绝对路径
|
||
editor_dir = QDir("_internal/editor")
|
||
editor_path = editor_dir.absoluteFilePath("min/vs/editor/editor.main.js")
|
||
editor_file = QFileInfo(editor_path)
|
||
base_url = QUrl.fromLocalFile(editor_file.absolutePath())
|
||
|
||
# 检测文本语言
|
||
language = self._detect_language(content)
|
||
logger.info(f"检测到文本语言类型: {language}")
|
||
|
||
# 构建HTML内容,使用ES模块方式加载Monaco Editor
|
||
html_content = f'''
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
body, html {{
|
||
margin: 0;
|
||
padding: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||
}}
|
||
#container {{
|
||
width: 100%;
|
||
height: 100%;
|
||
}}
|
||
</style>
|
||
<script src="vs/loader.js"></script>
|
||
<script>
|
||
let editor;
|
||
|
||
require.config({{ paths: {{ 'vs': './vs' }} }});
|
||
require(['vs/editor/editor.main'], function() {{
|
||
editor = monaco.editor.create(document.getElementById('container'), {{
|
||
value: `{content.replace('`', '\\`')}`,
|
||
}});
|
||
// 重新获取编辑器实例以确保正确初始化
|
||
setTimeout(() => {{
|
||
editor = monaco.editor.getModels()[0] ? monaco.editor.getModels()[0].getContainerInfo().domNode.monacoEditor : null;
|
||
}}, 100);'\\`'}}`,
|
||
language: '{language}',
|
||
theme: 'vs-dark',
|
||
automaticLayout: true,
|
||
minimap: {{ enabled: true }},
|
||
scrollBeyondLastLine: false,
|
||
fontSize: 14,
|
||
wordWrap: 'on',
|
||
lineNumbers: 'on',
|
||
readOnly: false,
|
||
fontFamily: 'Consolas, "Microsoft YaHei", monospace'
|
||
}});
|
||
|
||
// 添加内容变化监听器
|
||
editor.onDidChangeModelContent(function() {{
|
||
// 可以在这里添加内容变化的处理逻辑
|
||
}});
|
||
}});
|
||
|
||
// 暴露获取内容的方法给QWebEngineView调用
|
||
function getEditorContent() {{
|
||
return editor ? editor.getValue() : '';
|
||
}}
|
||
|
||
// 暴露设置内容的方法
|
||
function setEditorContent(content) {{
|
||
if (editor) {{
|
||
editor.setValue(content);
|
||
}}
|
||
}}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div id="container"></div>
|
||
</body>
|
||
</html>
|
||
'''
|
||
|
||
# 由于我们已经切换到外部浏览器编辑模式,这里不再需要加载HTML内容到textEdit
|
||
# self.textEdit.setHtml(html_content, base_url) - 已注释,因为textEdit不再存在
|
||
|
||
def _detect_language(self, content):
|
||
"""检测文本语言类型"""
|
||
content_lower = content.lower()
|
||
|
||
if 'public class' in content_lower and ('import ' in content_lower or 'package ' in content_lower):
|
||
return 'java'
|
||
elif 'def ' in content_lower and ('import ' in content_lower or 'print(' in content_lower):
|
||
return 'python'
|
||
elif '<!doctype html>' in content_lower or '<html>' in content_lower:
|
||
return 'html'
|
||
elif '{' in content_lower and '}' in content_lower and ':' in content_lower:
|
||
return 'json'
|
||
elif '{' in content_lower and '}' in content_lower and ('function' in content_lower or 'const ' in content_lower):
|
||
return 'javascript'
|
||
elif '<?xml' in content_lower:
|
||
return 'xml'
|
||
elif '<?php' in content_lower:
|
||
return 'php'
|
||
elif 'class ' in content_lower and '{' in content_lower and '}' in content_lower and ';' in content_lower:
|
||
return 'cpp'
|
||
elif 'select ' in content_lower and 'from ' in content_lower:
|
||
return 'sql'
|
||
elif '#include' in content_lower:
|
||
return 'cpp'
|
||
elif '<script' in content_lower:
|
||
return 'html'
|
||
elif 'function' in content_lower and '{' in content_lower and '}' in content_lower:
|
||
return 'javascript'
|
||
elif 'namespace' in content_lower and '{' in content_lower and '}' in content_lower:
|
||
return 'cpp'
|
||
elif 'var ' in content_lower and '=' in content_lower:
|
||
return 'javascript'
|
||
elif 'using namespace' in content_lower:
|
||
return 'cpp'
|
||
elif 'struct ' in content_lower and '{' in content_lower:
|
||
return 'cpp'
|
||
elif '#' in content_lower and 'define' in content_lower:
|
||
return 'cpp'
|
||
else:
|
||
return 'text'
|
||
|
||
# JavaScript执行完成的处理已移除
|
||
|
||
def _saveContent(self, content):
|
||
"""保存编辑器内容并提交修改"""
|
||
logger.info(f"保存文本文件修改,文件ID: {self._id}")
|
||
self.saveTextThread = UpdateFileContentThread(
|
||
self._id,
|
||
content,
|
||
)
|
||
self.saveTextThread.successUpdated.connect(self._successSave)
|
||
self.saveTextThread.errorUpdated.connect(self._errorSave)
|
||
self.saveTextThread.start()
|
||
|
||
def getTextContent(self):
|
||
"""获取编辑器内容"""
|
||
return self.editorContent
|
||
|
||
def resizeEvent(self, event):
|
||
"""重写窗口大小改变事件"""
|
||
super().resizeEvent(event)
|
||
|
||
def closeEvent(self, event):
|
||
"""关闭事件,清理临时文件、定时器和Web服务器"""
|
||
# 停止轮询定时器
|
||
if hasattr(self, 'pollingTimer'):
|
||
self.pollingTimer.stop()
|
||
logger.info("轮询定时器已停止")
|
||
|
||
# 停止Web服务器
|
||
if hasattr(self, 'httpd'):
|
||
try:
|
||
logger.info("正在停止本地Web服务器...")
|
||
self.httpd.shutdown()
|
||
self.httpd.server_close()
|
||
logger.info("本地Web服务器已成功停止")
|
||
except Exception as e:
|
||
logger.error(f"停止Web服务器失败: {e}")
|
||
|
||
# 清理临时文件
|
||
if hasattr(self, 'tempFilePath') and os.path.exists(self.tempFilePath):
|
||
try:
|
||
os.unlink(self.tempFilePath)
|
||
logger.info(f"临时文件已清理: {self.tempFilePath}")
|
||
except Exception as e:
|
||
logger.error(f"清理临时文件失败: {e}")
|
||
|
||
# 调用父类方法并接受事件
|
||
super().closeEvent(event)
|
||
event.accept()
|
||
|