Sfoglia il codice sorgente

'在线编辑功能实现'

admincjy 1 mese fa
parent
commit
399dedf1d2
4 ha cambiato i file con 529 aggiunte e 15 eliminazioni
  1. 172 12
      public/css/style.css
  2. 288 1
      public/js/reader.js
  3. 21 1
      public/reader.html
  4. 48 1
      server.js

+ 172 - 12
public/css/style.css

@@ -918,11 +918,9 @@ body {
     }
 }
 
-/* 回到顶部按钮 */
-.back-to-top {
+/* 悬浮操作按钮(通用样式) */
+.floating-action-btn {
     position: fixed;
-    bottom: 40px;
-    right: 40px;
     width: 50px;
     height: 50px;
     background: var(--primary-color);
@@ -940,23 +938,159 @@ body {
     outline: none;
 }
 
-.back-to-top:hover {
+.floating-action-btn:hover {
     opacity: 1;
     transform: translateY(-3px);
     box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
 }
 
-.back-to-top:active {
+.floating-action-btn:active {
     transform: translateY(-1px);
 }
 
+.floating-action-btn svg {
+    display: block;
+}
+
+/* 回到顶部按钮 */
+.back-to-top {
+    bottom: 40px;
+    right: 40px;
+}
+
 .back-to-top.hidden {
     opacity: 0;
     pointer-events: none;
 }
 
-.back-to-top svg {
-    display: block;
+/* 编辑按钮 */
+.edit-btn {
+    bottom: 40px;
+    right: 110px; /* 在回到顶部按钮左侧 */
+}
+
+.edit-btn.hidden {
+    opacity: 0;
+    pointer-events: none;
+}
+
+/* 编辑按钮激活状态 */
+.edit-btn.active {
+    opacity: 1 !important;
+    background: #28a745;
+    box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
+}
+
+.edit-btn.active:hover {
+    background: #218838;
+    box-shadow: 0 6px 16px rgba(40, 167, 69, 0.5);
+}
+
+/* 编辑器容器 */
+.editor-container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: white;
+    z-index: 10;
+    display: flex;
+    flex-direction: column;
+}
+
+.editor-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 20px 30px;
+    background: var(--sidebar-bg);
+    border-bottom: 2px solid var(--border-color);
+}
+
+.editor-header h3 {
+    margin: 0;
+    font-size: 1.2rem;
+    color: var(--text-color);
+}
+
+.editor-header h3 span {
+    color: var(--primary-color);
+    font-weight: 600;
+}
+
+.editor-actions {
+    display: flex;
+    gap: 12px;
+}
+
+.editor-action-btn {
+    padding: 10px 20px;
+    border: none;
+    border-radius: 6px;
+    font-size: 0.95rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+}
+
+.save-btn {
+    background: var(--primary-color);
+    color: white;
+}
+
+.save-btn:hover {
+    background: var(--primary-hover);
+}
+
+.cancel-btn {
+    background: #6c757d;
+    color: white;
+}
+
+.cancel-btn:hover {
+    background: #5a6268;
+}
+
+/* Markdown编辑器 */
+.markdown-editor {
+    flex: 1;
+    padding: 30px;
+    border: none;
+    outline: none;
+    font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+    font-size: 14px;
+    line-height: 1.6;
+    resize: none;
+    background: white;
+    color: var(--text-color);
+}
+
+.markdown-editor::placeholder {
+    color: #656d76;
+}
+
+/* 成功提示动画 */
+@keyframes slideInRight {
+    from {
+        opacity: 0;
+        transform: translateX(100px);
+    }
+    to {
+        opacity: 1;
+        transform: translateX(0);
+    }
+}
+
+@keyframes slideOutRight {
+    from {
+        opacity: 1;
+        transform: translateX(0);
+    }
+    to {
+        opacity: 0;
+        transform: translateX(100px);
+    }
 }
 
 /* 移动端侧边栏 */
@@ -974,12 +1108,38 @@ body {
         transform: translateX(100%) !important;
     }
 
-    /* 移动端回到顶部按钮调整 */
-    .back-to-top {
-        bottom: 100px !important;
-        right: 20px !important;
+    /* 移动端悬浮按钮调整 */
+    .floating-action-btn {
         width: 45px !important;
         height: 45px !important;
         z-index: 98 !important;
     }
+
+    .back-to-top {
+        bottom: 100px !important;
+        right: 20px !important;
+    }
+
+    .edit-btn {
+        bottom: 100px !important;
+        right: 75px !important;
+    }
+
+    .editor-header {
+        padding: 15px 20px;
+    }
+
+    .editor-header h3 {
+        font-size: 1rem;
+    }
+
+    .editor-action-btn {
+        padding: 8px 16px;
+        font-size: 0.9rem;
+    }
+
+    .markdown-editor {
+        padding: 20px;
+        font-size: 13px;
+    }
 }

+ 288 - 1
public/js/reader.js

@@ -1,6 +1,9 @@
 let currentCategory = '';
 let currentDoc = '';
 let docList = [];
+let currentMarkdownContent = ''; // 保存当前文档的markdown源码
+let isEditMode = false; // 是否处于编辑模式
+let savedScrollPosition = 0; // 保存的滚动位置
 
 // DOM 元素缓存
 const DOM = {
@@ -9,7 +12,11 @@ const DOM = {
     docNav: null,
     toc: null,
     leftSidebar: null,
-    rightSidebar: null
+    rightSidebar: null,
+    editBtn: null,
+    editorContainer: null,
+    markdownEditor: null,
+    editorDocName: null
 };
 
 // 配置 marked
@@ -143,6 +150,7 @@ async function loadDocument(docName, scrollToText = null) {
         }
 
         currentDoc = docName;
+        currentMarkdownContent = data.content; // 保存markdown源码
 
         // 渲染 Markdown
         const html = marked.parse(data.content);
@@ -731,12 +739,291 @@ function setupBackToTop() {
     });
 }
 
+// 编辑功能
+function setupEditFeature() {
+    // 缓存DOM元素
+    if (!DOM.editBtn) DOM.editBtn = document.getElementById('edit-btn');
+    if (!DOM.editorContainer) DOM.editorContainer = document.getElementById('editor-container');
+    if (!DOM.markdownEditor) DOM.markdownEditor = document.getElementById('markdown-editor');
+    if (!DOM.editorDocName) DOM.editorDocName = document.getElementById('editor-doc-name');
+
+    const saveBtn = document.getElementById('save-btn');
+    const cancelBtn = document.getElementById('cancel-edit-btn');
+    const contentArea = document.getElementById('content-area');
+
+    // 编辑按钮点击事件 - 切换编辑/查看模式
+    DOM.editBtn.addEventListener('click', () => {
+        if (isEditMode) {
+            exitEditMode();
+        } else {
+            enterEditMode();
+        }
+    });
+
+    // 取消编辑
+    cancelBtn.addEventListener('click', () => {
+        exitEditMode();
+    });
+
+    // 保存按钮
+    saveBtn.addEventListener('click', async () => {
+        await saveDocument();
+    });
+}
+
+// 进入编辑模式
+function enterEditMode() {
+    if (!currentDoc || !currentMarkdownContent) {
+        alert('请先加载一个文档');
+        return;
+    }
+
+    isEditMode = true;
+
+    // 更新编辑按钮状态
+    DOM.editBtn.classList.add('active');
+    DOM.editBtn.title = '退出编辑';
+
+    // 保存当前滚动位置
+    const contentArea = document.getElementById('content-area');
+    savedScrollPosition = contentArea.scrollTop;
+
+    // 找到当前视口中心附近的元素
+    const visibleElement = findVisibleElement();
+
+    // 隐藏渲染的内容
+    if (!DOM.content) DOM.content = document.getElementById('markdown-content');
+    DOM.content.style.display = 'none';
+
+    // 显示编辑器
+    DOM.editorContainer.style.display = 'flex';
+    DOM.editorDocName.textContent = `${currentDoc}.md`;
+    DOM.markdownEditor.value = currentMarkdownContent;
+
+    // 定位光标到对应位置
+    setTimeout(() => {
+        if (visibleElement) {
+            positionCursorByElement(visibleElement);
+        } else {
+            // 如果找不到元素,使用滚动比例
+            const scrollRatio = savedScrollPosition / contentArea.scrollHeight;
+            DOM.markdownEditor.scrollTop = DOM.markdownEditor.scrollHeight * scrollRatio;
+        }
+        DOM.markdownEditor.focus();
+    }, 50);
+}
+
+// 找到当前视口中可见的元素
+function findVisibleElement() {
+    const contentArea = document.getElementById('content-area');
+    const viewportTop = contentArea.scrollTop;
+    const viewportMiddle = viewportTop + contentArea.clientHeight / 3; // 视口上方1/3处
+
+    // 查找所有重要元素(标题、段落等)
+    const elements = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote, pre');
+
+    let closestElement = null;
+    let minDistance = Infinity;
+
+    elements.forEach(el => {
+        const rect = el.getBoundingClientRect();
+        const elementTop = el.offsetTop;
+        const distance = Math.abs(elementTop - viewportMiddle);
+
+        if (distance < minDistance && elementTop <= viewportMiddle + 200) {
+            minDistance = distance;
+            closestElement = el;
+        }
+    });
+
+    return closestElement;
+}
+
+// 根据元素定位光标
+function positionCursorByElement(element) {
+    const elementText = element.textContent.trim();
+
+    if (!elementText) {
+        return;
+    }
+
+    // 获取元素标签类型
+    const tagName = element.tagName.toLowerCase();
+    let searchText = elementText;
+
+    // 如果是标题,添加markdown标记进行搜索
+    if (tagName.match(/^h[1-6]$/)) {
+        const level = parseInt(tagName[1]);
+        const hashes = '#'.repeat(level);
+
+        // 尝试匹配标题行
+        const lines = currentMarkdownContent.split('\n');
+        let targetLine = -1;
+
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i].trim();
+            // 匹配 "# 标题" 格式
+            if (line.startsWith(hashes + ' ') && line.substring(level + 1).trim() === elementText) {
+                targetLine = i;
+                break;
+            }
+        }
+
+        if (targetLine !== -1) {
+            setEditorCursorToLine(targetLine);
+            return;
+        }
+    }
+
+    // 对于其他元素,搜索文本内容
+    const lines = currentMarkdownContent.split('\n');
+    for (let i = 0; i < lines.length; i++) {
+        const line = lines[i].trim();
+        // 移除markdown标记进行比较
+        const cleanLine = line.replace(/^[#\-*>]+\s*/, '').trim();
+
+        if (cleanLine.includes(elementText.substring(0, Math.min(50, elementText.length)))) {
+            setEditorCursorToLine(i);
+            return;
+        }
+    }
+}
+
+// 设置编辑器光标到指定行
+function setEditorCursorToLine(lineNumber) {
+    const lines = currentMarkdownContent.split('\n');
+
+    // 计算目标位置的字符索引
+    let charPosition = 0;
+    for (let i = 0; i < lineNumber && i < lines.length; i++) {
+        charPosition += lines[i].length + 1; // +1 for newline
+    }
+
+    // 设置光标位置
+    DOM.markdownEditor.setSelectionRange(charPosition, charPosition);
+
+    // 滚动到光标位置
+    const linesBeforeCursor = lineNumber;
+    const lineHeight = 22; // 大约的行高(px)
+    const targetScrollTop = linesBeforeCursor * lineHeight - DOM.markdownEditor.clientHeight / 3;
+
+    DOM.markdownEditor.scrollTop = Math.max(0, targetScrollTop);
+}
+
+// 退出编辑模式
+function exitEditMode() {
+    isEditMode = false;
+
+    // 更新编辑按钮状态
+    DOM.editBtn.classList.remove('active');
+    DOM.editBtn.title = '编辑文档';
+
+    // 隐藏编辑器
+    DOM.editorContainer.style.display = 'none';
+
+    // 显示渲染的内容
+    DOM.content.style.display = 'block';
+
+    // 恢复滚动位置
+    const contentArea = document.getElementById('content-area');
+    setTimeout(() => {
+        contentArea.scrollTop = savedScrollPosition;
+    }, 0);
+}
+
+// 保存文档
+async function saveDocument() {
+    const newContent = DOM.markdownEditor.value;
+
+    if (!currentCategory || !currentDoc) {
+        alert('无法保存:缺少文档信息');
+        return;
+    }
+
+    // 禁用保存按钮,防止重复点击
+    const saveBtn = document.getElementById('save-btn');
+    saveBtn.disabled = true;
+    saveBtn.textContent = '保存中...';
+
+    try {
+        const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(currentDoc)}`, {
+            method: 'PUT',
+            headers: {
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({
+                content: newContent
+            })
+        });
+
+        if (!response.ok) {
+            const errorData = await response.json().catch(() => ({}));
+            throw new Error(errorData.error || `保存失败 (${response.status})`);
+        }
+
+        const result = await response.json();
+
+        // 更新本地的markdown内容
+        currentMarkdownContent = newContent;
+
+        // 重新渲染内容
+        const html = marked.parse(newContent);
+        DOM.content.innerHTML = html;
+
+        // 重新生成TOC
+        generateTOC();
+
+        // 退出编辑模式
+        exitEditMode();
+
+        // 显示成功提示
+        showSuccessMessage('保存成功!');
+
+    } catch (error) {
+        console.error('Save error:', error);
+        alert(`保存失败:${error.message || '请稍后重试'}`);
+    } finally {
+        // 恢复保存按钮
+        saveBtn.disabled = false;
+        saveBtn.textContent = '保存';
+    }
+}
+
+// 显示成功提示
+function showSuccessMessage(message) {
+    const toast = document.createElement('div');
+    toast.className = 'success-toast';
+    toast.textContent = message;
+    toast.style.cssText = `
+        position: fixed;
+        top: 20px;
+        right: 20px;
+        background: #28a745;
+        color: white;
+        padding: 12px 24px;
+        border-radius: 6px;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+        z-index: 10000;
+        animation: slideInRight 0.3s ease-out;
+    `;
+
+    document.body.appendChild(toast);
+
+    setTimeout(() => {
+        toast.style.animation = 'slideOutRight 0.3s ease-out';
+        setTimeout(() => {
+            document.body.removeChild(toast);
+        }, 300);
+    }, 2000);
+}
+
 // 初始化
 document.addEventListener('DOMContentLoaded', () => {
     loadDocList();
     setupSidebarToggles();
     initSearch();
     setupBackToTop();
+    setupEditFeature();
 });
 
 // 处理浏览器后退/前进

+ 21 - 1
public/reader.html

@@ -67,12 +67,32 @@
                 <!-- Markdown 内容将在这里渲染 -->
             </article>
 
+            <!-- 编辑按钮 -->
+            <button id="edit-btn" class="floating-action-btn edit-btn" title="编辑文档">
+                <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                    <path d="M18.5 2.50001C18.8978 2.10219 19.4374 1.87869 20 1.87869C20.5626 1.87869 21.1022 2.10219 21.5 2.50001C21.8978 2.89784 22.1213 3.43741 22.1213 4.00001C22.1213 4.56262 21.8978 5.10219 21.5 5.50001L12 15L8 16L9 12L18.5 2.50001Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+            </button>
+
             <!-- 回到顶部按钮 -->
-            <button id="back-to-top" class="back-to-top" title="回到顶部">
+            <button id="back-to-top" class="floating-action-btn back-to-top" title="回到顶部">
                 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                     <path d="M12 19V5M12 5L5 12M12 5L19 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                 </svg>
             </button>
+
+            <!-- 编辑器容器 -->
+            <div id="editor-container" class="editor-container" style="display: none;">
+                <div class="editor-header">
+                    <h3>编辑文档:<span id="editor-doc-name"></span></h3>
+                    <div class="editor-actions">
+                        <button id="save-btn" class="editor-action-btn save-btn">保存</button>
+                        <button id="cancel-edit-btn" class="editor-action-btn cancel-btn">取消</button>
+                    </div>
+                </div>
+                <textarea id="markdown-editor" class="markdown-editor" spellcheck="false"></textarea>
+            </div>
         </main>
 
         <!-- 右侧 TOC -->

+ 48 - 1
server.js

@@ -29,7 +29,8 @@ function validatePath(fullPath, baseDir) {
 
 // 中间件
 app.use(cors());
-app.use(express.json());
+app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制到10MB
+app.use(express.urlencoded({ limit: '10mb', extended: true }));
 app.use(express.static('public'));
 
 // API: 获取 index.md 内容
@@ -108,6 +109,52 @@ app.get('/api/doc/:category/:docName', async (req, res) => {
   }
 });
 
+// API: 保存文档内容
+app.put('/api/doc/:category/:docName', async (req, res) => {
+  try {
+    const category = sanitizePath(req.params.category);
+    const docName = sanitizePath(req.params.docName);
+    const { content } = req.body;
+
+    // 验证输入
+    if (!category || !docName) {
+      return res.status(400).json({ error: '无效的分类或文档名称' });
+    }
+
+    if (!content || typeof content !== 'string') {
+      return res.status(400).json({ error: '无效的文档内容' });
+    }
+
+    const docsDir = path.join(__dirname, 'docs');
+    const docPath = path.join(docsDir, category, `${docName}.md`);
+
+    // 验证路径是否在 docs 目录内
+    if (!validatePath(docPath, docsDir)) {
+      return res.status(403).json({ error: '拒绝访问:无效的路径' });
+    }
+
+    // 检查文件是否存在
+    try {
+      await fs.access(docPath);
+    } catch (error) {
+      return res.status(404).json({ error: '文档不存在' });
+    }
+
+    // 保存文件
+    await fs.writeFile(docPath, content, 'utf-8');
+
+    res.json({
+      success: true,
+      message: '文档保存成功',
+      category,
+      docName
+    });
+  } catch (error) {
+    console.error('Save document error:', error);
+    res.status(500).json({ error: '无法保存文档', details: error.message });
+  }
+});
+
 // API: 搜索文档
 app.get('/api/search/:category', async (req, res) => {
   try {