Compare commits

..

2 Commits

Author SHA1 Message Date
mula.liu f1ba8949f6 优化了编辑模式 2026-10-04 02:16:13 +08:00
mula.liu 3d50f2f51a 优化了编辑模式 2026-10-04 02:09:28 +08:00
2 changed files with 218 additions and 38 deletions

View File

@ -270,17 +270,14 @@
background-color: var(--item-hover-bg); background-color: var(--item-hover-bg);
} }
/* ByteMD 内置的右侧开关:1=文档索引 2=帮助 3=仅编辑 4=仅预览 5=全屏 6=同步滚动。 /* ByteMD 内置的右侧按钮:1=目录 2=帮助 3=仅编辑 4=仅预览 5=全屏 6=源代码。
1/2 依赖预览区渲染(纯编辑区下是空面板),3/4 与页面自身的「编辑/分栏/预览」分段控件重复, 1/2 依赖预览区渲染,3/4 与页面自身的「编辑 / 分栏 / 预览」分段控件重复,统一隐藏;
统一隐藏,只保留全屏与同步滚动。 5 由页面自绘、并入同一分段控件的「全屏」取代(DOM 仍保留,靠它驱动 ByteMD 的全屏状态);
6「源代码」是 ByteMD 指向项目主页的外链,不属于文档编辑操作,编辑模式下统一去掉。
隐藏后右侧只剩页面 Portal 注入的统一视图控件。
注意:页面用 Portal 把分段控件插到 .bytemd-toolbar-right 的最后一个子节点, 注意:页面用 Portal 把分段控件插到 .bytemd-toolbar-right 的最后一个子节点,
上面的序号才不会被错位,改动插入位置时记得同步这里的 nth-child。 */ 上面的序号才不会被错位,改动插入位置时记得同步这里的 nth-child。 */
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(-n+4) { .bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(-n+6) {
display: none;
}
/* 非分栏模式下没有左右对照,同步滚动开关同样隐藏 */
.bytemd-wrapper:not([data-view-mode="split"]) .bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(6) {
display: none; display: none;
} }
@ -290,10 +287,10 @@
align-items: center; align-items: center;
} }
/* 视图模式切换(Portal 注入):排在同步滚动 / 全屏图标左侧,与它们同宽同高 */ /* 视图模式切换(Portal 注入):编辑 / 分栏 / 预览 / 全屏 合成同一分段控件,
ByteMD 自带图标已全部隐藏,它成为工具栏右侧唯一的控件 */
.bytemd-toolbar-right .view-mode-switch { .bytemd-toolbar-right .view-mode-switch {
order: -1; order: -1;
margin-right: 6px;
line-height: 1; line-height: 1;
} }
@ -312,6 +309,11 @@
font-size: 14px; font-size: 14px;
} }
/* 全屏是开关动作而非视图选项:用主色区分「已进入全屏」,图标同时切换为退出样式 */
.bytemd-toolbar-right .view-mode-switch .view-mode-option.is-active {
color: var(--color-primary);
}
/* 预览模式是只读视图:隐藏会改动源码的 Markdown 工具栏,以及失效的同步滚动开关 */ /* 预览模式是只读视图:隐藏会改动源码的 Markdown 工具栏,以及失效的同步滚动开关 */
.bytemd-wrapper[data-view-mode="preview"] .bytemd-toolbar-left { .bytemd-wrapper[data-view-mode="preview"] .bytemd-toolbar-left {
display: none; display: none;
@ -340,6 +342,102 @@
justify-content: flex-end; 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 中) */ /* 编辑器工具栏里的视图模式图标(文案放在 Tooltip 中) */
.view-mode-option { .view-mode-option {
display: inline-flex; display: inline-flex;

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' 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 { import {
FolderOutlined, FolderOutlined,
FolderOpenOutlined, FolderOpenOutlined,
@ -19,6 +19,9 @@ import {
ShareAltOutlined, ShareAltOutlined,
ColumnWidthOutlined, ColumnWidthOutlined,
EyeOutlined, EyeOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
LoadingOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Editor } from '@bytemd/react' import { Editor } from '@bytemd/react'
@ -60,6 +63,8 @@ import './DocumentEditor.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
const ROOT_FOLDER_KEY = '__root__' const ROOT_FOLDER_KEY = '__root__'
// 自动保存间隔:每 30 秒检查一次,仅在存在未保存改动时静默保存
const AUTO_SAVE_INTERVAL_MS = 30 * 1000
function DocumentEditor() { function DocumentEditor() {
const { projectId } = useParams() const { projectId } = useParams()
@ -105,6 +110,8 @@ function DocumentEditor() {
const isLargeMarkdown = isLargeMarkdownContent(fileContent) const isLargeMarkdown = isLargeMarkdownContent(fileContent)
// 编辑区视图模式:默认只打开编辑区(不打开预览区),用户切换后记住偏好 // 编辑区视图模式:默认只打开编辑区(不打开预览区),用户切换后记住偏好
const [editorViewMode, setEditorViewMode] = useState(readEditorViewMode) const [editorViewMode, setEditorViewMode] = useState(readEditorViewMode)
// 自动保存:默认开启,每 30 秒静默保存一次;关闭后回到手动保存
const [autoSave, setAutoSave] = useState(true)
const editorPaneRef = useRef(null) const editorPaneRef = useRef(null)
useEffect(() => { useEffect(() => {
@ -206,6 +213,14 @@ function DocumentEditor() {
return () => window.removeEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true)
}, [isEditorFullscreen]) }, [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 linkTreeData = useMemo(() => {
const markSelectable = (nodes) => nodes.map((node) => { const markSelectable = (nodes) => nodes.map((node) => {
if (node.children?.length) { if (node.children?.length) {
@ -738,9 +753,10 @@ function DocumentEditor() {
}) })
} }
const handleSaveFile = async () => { // silent:自动保存时只更新基线,不弹成功提示(保存中的动画由按钮的 loading 状态承载)
const handleSaveFile = async ({ silent = false } = {}) => {
if (!selectedFile) { if (!selectedFile) {
Toast.warning('提示', '请先选择文件') if (!silent) Toast.warning('提示', '请先选择文件')
return return
} }
if (saving) return if (saving) return
@ -756,7 +772,9 @@ function DocumentEditor() {
if (snapshot === fileContent) { if (snapshot === fileContent) {
setContentBaseline(snapshot) setContentBaseline(snapshot)
} }
if (!silent) {
Toast.success('成功', '保存成功') Toast.success('成功', '保存成功')
}
} catch (error) { } catch (error) {
// 错误已通过request interceptor处理 // 错误已通过request interceptor处理
} finally { } finally {
@ -770,6 +788,23 @@ function DocumentEditor() {
saveFileRef.current = handleSaveFile 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(() => { useEffect(() => {
if (!isDirty) return undefined if (!isDirty) return undefined
@ -1444,17 +1479,45 @@ function DocumentEditor() {
未保存 未保存
</span> </span>
)} )}
<Tooltip title="保存(Ctrl/Cmd + S)"> {/* 保存按钮:自动保存开关融合在按钮内部,左半边点击保存,右半边开关自动保存 */}
<Button <div className="save-control">
type="primary" <Tooltip title={autoSave ? '立即保存(Ctrl/Cmd + S)' : '保存(Ctrl/Cmd + S)'}>
icon={<SaveOutlined />} <button
type="button"
className="save-control-main"
onClick={handleSaveFile} onClick={handleSaveFile}
loading={saving} disabled={!selectedFile || saving}
disabled={!selectedFile}
> >
保存 {saving ? <LoadingOutlined spin /> : <SaveOutlined />}
</Button> <span>{saving ? '保存中' : '保存'}</span>
</button>
</Tooltip> </Tooltip>
<span className="save-control-sep" aria-hidden="true" />
<Tooltip title={autoSave ? '关闭自动保存,改为手动保存' : '开启自动保存(每 30 秒自动保存)'}>
<span
className="save-control-auto"
role="switch"
aria-checked={autoSave}
aria-label="自动保存"
tabIndex={0}
onClick={() => setAutoSave((value) => !value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setAutoSave((value) => !value)
}
}}
>
<Switch
checked={autoSave}
checkedChildren="自动"
unCheckedChildren="手动"
tabIndex={-1}
aria-hidden="true"
/>
</span>
</Tooltip>
</div>
<Button <Button
icon={<UndoOutlined />} icon={<UndoOutlined />}
onClick={handleReset} onClick={handleReset}
@ -1567,15 +1630,23 @@ function DocumentEditor() {
locale={bytemdZhHans} locale={bytemdZhHans}
/> />
)} )}
{/* 视图模式切换:挂到 ByteMD 工具栏右侧,与内置「全屏」按钮同排 */} {/* 视图模式切换:编辑 / 分栏 / 预览 + 全屏,作为一个统一控件挂到 ByteMD 工具栏右侧。
全屏不是视图状态而是开关动作,点击后只切换 ByteMD 的全屏,不改变当前视图模式。 */}
{toolbarRightSlot && {toolbarRightSlot &&
createPortal( createPortal(
<Segmented <Segmented
className="view-mode-switch" className="view-mode-switch"
size="small" size="small"
value={editorViewMode} value={editorViewMode}
onChange={setEditorViewMode} onChange={(value) => {
options={EDITOR_VIEW_MODES.map((mode) => ({ if (value === 'fullscreen') {
toggleEditorFullscreen()
return
}
setEditorViewMode(value)
}}
options={[
...EDITOR_VIEW_MODES.map((mode) => ({
value: mode, value: mode,
label: ( label: (
<Tooltip title={`${EDITOR_VIEW_MODE_LABELS[mode]} · ${EDITOR_VIEW_MODE_HINTS[mode]}`}> <Tooltip title={`${EDITOR_VIEW_MODE_LABELS[mode]} · ${EDITOR_VIEW_MODE_HINTS[mode]}`}>
@ -1584,7 +1655,18 @@ function DocumentEditor() {
</span> </span>
</Tooltip> </Tooltip>
), ),
}))} })),
{
value: 'fullscreen',
label: (
<Tooltip title={isEditorFullscreen ? '退出全屏(Esc)' : '全屏'}>
<span className={`view-mode-option${isEditorFullscreen ? ' is-active' : ''}`}>
{isEditorFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</span>
</Tooltip>
),
},
]}
/>, />,
toolbarRightSlot, toolbarRightSlot,
)} )}