diff --git a/.dsh-plugins/pet-dock/cordis.patch.yml b/.dsh-plugins/pet-dock/cordis.patch.yml new file mode 100644 index 0000000..c94fbab --- /dev/null +++ b/.dsh-plugins/pet-dock/cordis.patch.yml @@ -0,0 +1,12 @@ +# @nex/pet-dock bundle patch: mounts this plugin into a profile's layer stack. +# +# This file is the `dsh.bundle.patch` layer of the package. Installing it with +# +# dsh plugin --profile add -w link: +# +# makes the command see `dsh.bundle` in the manifest and append `@nex/pet-dock` +# to the profile's `dsh.profile.bundles`, so the profile boot merges this +# `insert` row exactly like a hand-written cordis.patch.yml mount line. +- insert: + - id: pet-dock + name: '@nex/pet-dock' diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index e6479cb..a47e6b3 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -270,17 +270,14 @@ background-color: var(--item-hover-bg); } -/* ByteMD 内置的右侧开关:1=文档索引 2=帮助 3=仅编辑 4=仅预览 5=全屏 6=同步滚动。 - 1/2 依赖预览区渲染(纯编辑区下是空面板),3/4 与页面自身的「编辑/分栏/预览」分段控件重复, - 统一隐藏,只保留全屏与同步滚动。 +/* ByteMD 内置的右侧按钮:1=目录 2=帮助 3=仅编辑 4=仅预览 5=全屏 6=源代码。 + 1/2 依赖预览区渲染,3/4 与页面自身的「编辑 / 分栏 / 预览」分段控件重复,统一隐藏; + 5 由页面自绘、并入同一分段控件的「全屏」取代(DOM 仍保留,靠它驱动 ByteMD 的全屏状态); + 6「源代码」是 ByteMD 指向项目主页的外链,不属于文档编辑操作,编辑模式下统一去掉。 + 隐藏后右侧只剩页面 Portal 注入的统一视图控件。 注意:页面用 Portal 把分段控件插到 .bytemd-toolbar-right 的最后一个子节点, 上面的序号才不会被错位,改动插入位置时记得同步这里的 nth-child。 */ -.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(-n+4) { - display: none; -} - -/* 非分栏模式下没有左右对照,同步滚动开关同样隐藏 */ -.bytemd-wrapper:not([data-view-mode="split"]) .bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(6) { +.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(-n+6) { display: none; } @@ -290,10 +287,10 @@ align-items: center; } -/* 视图模式切换(Portal 注入):排在同步滚动 / 全屏图标左侧,与它们同宽同高 */ +/* 视图模式切换(Portal 注入):编辑 / 分栏 / 预览 / 全屏 合成同一分段控件, + ByteMD 自带图标已全部隐藏,它成为工具栏右侧唯一的控件 */ .bytemd-toolbar-right .view-mode-switch { order: -1; - margin-right: 6px; line-height: 1; } @@ -312,6 +309,11 @@ font-size: 14px; } +/* 全屏是开关动作而非视图选项:用主色区分「已进入全屏」,图标同时切换为退出样式 */ +.bytemd-toolbar-right .view-mode-switch .view-mode-option.is-active { + color: var(--color-primary); +} + /* 预览模式是只读视图:隐藏会改动源码的 Markdown 工具栏,以及失效的同步滚动开关 */ .bytemd-wrapper[data-view-mode="preview"] .bytemd-toolbar-left { display: none; @@ -340,6 +342,102 @@ justify-content: flex-end; } +/* 保存按钮:自动保存开关融合在按钮内部,整体呈现为一个主色按钮 + 左半边「保存」执行保存,右半边「自动保存」开关切换自动保存 */ +.save-control { + display: inline-flex; + align-items: stretch; + height: 32px; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--text-color-inverse); + overflow: hidden; + transition: background var(--transition-fast); +} + +.save-control:hover { + background: var(--color-primary-hover); +} + +.save-control-main { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border: 0; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; + transition: background var(--transition-fast); +} + +.save-control-main:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.14); +} + +.save-control-main:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.save-control-sep { + align-self: center; + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.35); +} + +.save-control-auto { + display: inline-flex; + align-items: center; + padding: 0 10px; + cursor: pointer; + user-select: none; + transition: background var(--transition-fast); +} + +.save-control-auto:hover { + background: rgba(255, 255, 255, 0.14); +} + +/* 开关本身不单独响应点击,由外层整块统一处理,避免重复触发 */ +.save-control-auto .ant-switch { + pointer-events: none; +} + +/* 主色按钮上的开关重新配色:开关内直接显示「自动 / 手动」,开 / 关都清晰可辨 */ +.save-control-auto .ant-switch { + background: rgba(0, 0, 0, 0.28); + /* antd 默认宽度放不下两个汉字,会折行/裁切,这里给足文本空间 */ + min-width: 58px; +} + +.save-control-auto .ant-switch .ant-switch-handle::before { + background: #fff; +} + +.save-control-auto .ant-switch.ant-switch-checked { + background: #fff; +} + +/* antd 把颜色写在子节点上,必须直接覆盖 checked / unchecked 文本,否则白字压白底看不见 */ +.save-control-auto .ant-switch .ant-switch-inner-checked, +.save-control-auto .ant-switch .ant-switch-inner-unchecked { + color: #fff; + font-size: 12px; + white-space: nowrap; +} + +.save-control-auto .ant-switch.ant-switch-checked .ant-switch-inner-checked { + color: var(--color-primary); +} + +.save-control-auto .ant-switch.ant-switch-checked .ant-switch-handle::before { + background: var(--color-primary); +} + /* 编辑器工具栏里的视图模式图标(文案放在 Tooltip 中) */ .view-mode-option { display: inline-flex; diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 4c15881..3952fef 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' -import { Layout, Button, Modal, Input, Space, Tooltip, Upload, Select, Progress, TreeSelect, Empty, Tabs, Segmented } from 'antd' +import { Layout, Button, Modal, Input, Space, Tooltip, Upload, Select, Progress, TreeSelect, Empty, Tabs, Segmented, Switch } from 'antd' import { FolderOutlined, FolderOpenOutlined, @@ -19,6 +19,9 @@ import { ShareAltOutlined, ColumnWidthOutlined, EyeOutlined, + FullscreenOutlined, + FullscreenExitOutlined, + LoadingOutlined, } from '@ant-design/icons' import { createPortal } from 'react-dom' import { Editor } from '@bytemd/react' @@ -60,6 +63,8 @@ import './DocumentEditor.css' const { Sider, Content } = Layout const ROOT_FOLDER_KEY = '__root__' +// 自动保存间隔:每 30 秒检查一次,仅在存在未保存改动时静默保存 +const AUTO_SAVE_INTERVAL_MS = 30 * 1000 function DocumentEditor() { const { projectId } = useParams() @@ -105,6 +110,8 @@ function DocumentEditor() { const isLargeMarkdown = isLargeMarkdownContent(fileContent) // 编辑区视图模式:默认只打开编辑区(不打开预览区),用户切换后记住偏好 const [editorViewMode, setEditorViewMode] = useState(readEditorViewMode) + // 自动保存:默认开启,每 30 秒静默保存一次;关闭后回到手动保存 + const [autoSave, setAutoSave] = useState(true) const editorPaneRef = useRef(null) useEffect(() => { @@ -206,6 +213,14 @@ function DocumentEditor() { return () => window.removeEventListener('keydown', onKeyDown, true) }, [isEditorFullscreen]) + // 全屏状态由 ByteMD 内部维护,页面自绘的全屏按钮通过点击它隐藏的原生图标来切换, + // 保证与自带行为的退出方式(Esc)完全一致,不额外维护一份全屏状态。 + const toggleEditorFullscreen = () => { + const pane = editorPaneRef.current + const icon = pane?.querySelector('.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(5)') + icon?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + const linkTreeData = useMemo(() => { const markSelectable = (nodes) => nodes.map((node) => { if (node.children?.length) { @@ -738,9 +753,10 @@ function DocumentEditor() { }) } - const handleSaveFile = async () => { + // silent:自动保存时只更新基线,不弹成功提示(保存中的动画由按钮的 loading 状态承载) + const handleSaveFile = async ({ silent = false } = {}) => { if (!selectedFile) { - Toast.warning('提示', '请先选择文件') + if (!silent) Toast.warning('提示', '请先选择文件') return } if (saving) return @@ -756,7 +772,9 @@ function DocumentEditor() { if (snapshot === fileContent) { setContentBaseline(snapshot) } - Toast.success('成功', '保存成功') + if (!silent) { + Toast.success('成功', '保存成功') + } } catch (error) { // 错误已通过request interceptor处理 } finally { @@ -770,6 +788,23 @@ function DocumentEditor() { saveFileRef.current = handleSaveFile }) + // 自动保存:开启时每 30 秒检查一次,仅在存在未保存改动且不处于加载/保存中时静默保存 + const autoSaveStatusRef = useRef({}) + useEffect(() => { + autoSaveStatusRef.current = { selectedFile, isPdfSelected, isDirty, saving, loading } + }) + + useEffect(() => { + if (!autoSave) return undefined + const timer = window.setInterval(() => { + const status = autoSaveStatusRef.current + if (!status.selectedFile || status.isPdfSelected || status.saving || status.loading) return + if (!status.isDirty) return + saveFileRef.current?.({ silent: true }) + }, AUTO_SAVE_INTERVAL_MS) + return () => window.clearInterval(timer) + }, [autoSave]) + // 存在未保存改动时,关闭标签页/刷新前给出浏览器级提示 useEffect(() => { if (!isDirty) return undefined @@ -1444,17 +1479,45 @@ function DocumentEditor() { 未保存 )} - - - + {/* 保存按钮:自动保存开关融合在按钮内部,左半边点击保存,右半边开关自动保存 */} +
+ + + +