From 0f25205e4941737f248083ec4e59ba12a6f4660d Mon Sep 17 00:00:00 2001 From: liubw Date: Thu, 12 Nov 2026 16:38:42 +0800 Subject: [PATCH 1/3] feat(ui): unify toolbar menus and add list-gallery browsing Move the document More action into the editing group, separate list/table/insert menus, reorganize settings into General/Note Management/Data, and place Settings at the bottom of the sidebar as a plain navigation row. Add list and compact gallery presentations with per-folder view preferences; folders also switch between list rows and cards. Keep the toolbar complete by collapsing the sidebar, then the list, before narrowing the editor, and hide the title and category chips once the sidebar is gone. --- CMakeLists.txt | 4 +- README.md | 4 +- docs/notes-interaction-model.md | 16 +- qml/EditorTools.qml | 55 ++-- qml/FolderBrowser.qml | 74 +++++ qml/Main.qml | 474 ++++++++++++++++++++------------ qml/NotesBrowser.qml | 118 ++++++++ qml/NotesOrganizer.qml | 15 + qml/NotesSettings.qml | 39 +-- tests/chrome/tst_browser.qml | 91 ++++++ tests/chrome/tst_folders.qml | 86 ++++++ tests/chrome/tst_selection.qml | 25 ++ tests/chrome/tst_settings.qml | 45 +++ 13 files changed, 818 insertions(+), 228 deletions(-) create mode 100644 qml/FolderBrowser.qml create mode 100644 qml/NotesBrowser.qml create mode 100644 tests/chrome/tst_browser.qml create mode 100644 tests/chrome/tst_folders.qml create mode 100644 tests/chrome/tst_settings.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 2fe16f8..c4d330c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,7 @@ target_link_libraries(nextnotes-storage PUBLIC Qt6::Core SQLite::SQLite3) qt_add_executable(nextnotes src/main.cpp src/EditorSession.cpp src/EditorSessionWeb.cpp src/EditorSession.h src/NotesController.cpp src/NotesLibrary.cpp src/EditorExport.cpp src/PdfExport.cpp src/NotesOrganizer.cpp src/NotesKnowledge.cpp src/NotesController.h) target_sources(nextnotes PRIVATE src/EditorRenderLifecycle.cpp src/EditorRenderLifecycle.h) qt_add_qml_module(nextnotes URI NextNotes VERSION 1.0 RESOURCE_PREFIX /qt/qml - QML_FILES qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml) + QML_FILES qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml) qt_add_resources(nextnotes window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml) qt_add_resources(nextnotes app-icon PREFIX /nextnotes/icons BASE packaging FILES packaging/org.nextks.notes.svg) file(GLOB_RECURSE EDITOR_ASSETS CONFIGURE_DEPENDS web/editor/dist/* web/editor/vendor/*) @@ -33,7 +33,7 @@ if(NEXTNOTES_BUILD_TESTS) enable_testing() qt_add_executable(notes-chrome-test tests/chrome/main.cpp) qt_add_qml_module(notes-chrome-test URI NextNotes.ChromeTests VERSION 1.0 RESOURCE_PREFIX /qt/qml - QML_FILES qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml + QML_FILES qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSettings.qml qml/NotesPopover.qml qml/NotesContentPopover.qml qml/PopupPlacement.js) qt_add_resources(notes-chrome-test test-window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml) target_compile_definitions(notes-chrome-test PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/chrome") diff --git a/README.md b/README.md index de00785..a439740 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ - GFM 表格:输入完整表头与分隔行后按 Enter、粘贴 Markdown 表格,或输入 `|3x3| ` 建立三列三行表格。顶部“表格与行列操作”提供增删行列、列对齐、删表和退出;表格内 Tab / Shift+Tab 导航,Ctrl+Enter 退出。 - Markdown 及富文本粘贴、统一撤销/重做;格式按钮和快捷键调用同一编辑内核命令。 +笔记浏览栏上方“更多”菜单提供列表/画廊切换,各文件夹分别记住视图;子文件夹同步显示为列表或卡片。画廊显示紧凑的标题、摘要和日期卡片,沿用排序、多选、右键及拖放归类。设置入口位于左侧栏底部。设置按通用、笔记管理、数据分组,具体入口见[界面交互说明](docs/notes-interaction-model.md)。 + 本轮块拖动、上下文菜单、完整斜杠菜单与紧凑查找栏的调整、验证范围和效果图见[编辑器交互复查](docs/editor-interaction-review.md)。 -正文工具已补齐:`/` 双语过滤与键盘选择、块拖动/移动/复制/多选、大纲与标题折叠、`[toc]` 实时目录、查找替换与字数统计、图片/附件导入与可恢复清理、图片显示宽度、代码语言/高亮/复制、离线 Mermaid、KaTeX 行内/块公式、脚注、英文拼写检查,以及独立 HTML / PDF 导出与 PDF 打印预览。工具入口为格式栏“正文编辑与导出”;Ctrl+H 打开替换。公式可通过工具菜单或 `/` 插入,双击公式编辑。图片/表格宽度保存在应用偏好中,不扩展 Markdown 语法。折叠仅影响编辑视图,导出会展开全文。附件清理先等待正文保存,再检查磁盘引用,移入专用回收区,可恢复。 +正文工具已补齐:`/` 双语过滤与键盘选择、块拖动/移动/复制/多选、大纲与标题折叠、`[toc]` 实时目录、查找替换与字数统计、图片/附件导入与可恢复清理、图片显示宽度、代码语言/高亮/复制、离线 Mermaid、KaTeX 行内/块公式、脚注、英文拼写检查,以及独立 HTML / PDF 导出与 PDF 打印预览。常用格式操作直接位于顶部按钮组,“列表”“表格”“插入”各自独立;唯一“更多”提供文档操作、源码、大纲及图片/代码/附件管理;Ctrl+H 打开替换。公式可通过工具菜单或 `/` 插入,双击公式编辑。图片/表格宽度保存在应用偏好中,不扩展 Markdown 语法。折叠仅影响编辑视图,导出会展开全文。附件清理先等待正文保存,再检查磁盘引用,移入专用回收区,可恢复。 `web/editor/editor.js` 负责上游插件配置与命令适配;`qml/SourceEditor.qml` 通过 WebChannel 传递主题、焦点与工作区位置;`src/EditorSessionWeb.cpp` 缓存已提交 Markdown,并按文档代次和递增序号拒绝过期消息。现有 `EditorSession::execute/link/undo/redo` 和 controller 存储接口保留。切换笔记、切库和关闭前先向编辑区取得最新已提交状态;自动保存回包不重建编辑器或撤销栈。 diff --git a/docs/notes-interaction-model.md b/docs/notes-interaction-model.md index e3d3fa7..480330f 100644 --- a/docs/notes-interaction-model.md +++ b/docs/notes-interaction-model.md @@ -4,12 +4,12 @@ ## 日常界面 -- 顶部左侧放新建和当前笔记操作,中间用一个公共按钮组统一承载撤销/重做、段落样式、粗体/斜体/删除线、插入、表格和更多编辑工具,右侧放搜索和设置;正文不再单独占用一行格式工具栏。编辑按钮组优先居中,空间不足时避开左右操作并支持横向滚动,键盘聚焦时跟随显示;格式状态并入底部状态栏。 +- 顶部左侧放新建,中间公共按钮组提供配套的撤销/重做、段落样式、粗体/斜体/删除线、行内代码,以及独立的引用、代码块、链接和任务清单。列表菜单只放列表与缩进;插入菜单放图片/附件、公式、脚注、目录和分隔线;表格保留全部行列操作。唯一正文“更多”位于按钮组末尾,按钮组支持横向滚动和键盘焦点显露;右侧搜索默认收起为图标按钮,点击或按搜索快捷键后展开输入框;宽窗口不自动展开。顶部液态按钮统一使用中档强度,按钮组竖线高 12px、在主题边框原有透明度上再乘 0.65,正文区域最小宽 720px;缩小窗口时正文不再继续变窄:先收起左侧导航,再收起笔记列表,最后只剩正文单栏,窗口最小宽度即正文最小宽度。侧栏隐藏时(列表+正文或仅正文)不再显示“笔记”标题和“全部/收藏/最近删除”分类按钮,顶部只保留侧栏按钮;列表+正文时点击该按钮展开侧栏并由列表让出位置,再点一次返回列表。拖动浏览栏分隔条同样不会把正文压到该宽度以下,设置放在左侧栏底部,使用普通导航按钮。 - 侧栏分为“视图”和“文件夹”:视图包含全部笔记、收藏、最近删除及已保存的搜索筛选;文件夹包含“随手记”、初始化时添加的“默认文件夹”和用户创建的顶层文件夹;子文件夹放在对应笔记列表上方,通过返回上一级逐层导航。侧栏底部不显示新建文件夹,暂不增加“新建视图”入口;保存搜索仍从设置进入。 - “随手记”和“默认文件夹”都是仅初始化一次的普通文件夹,支持重命名、移动、删除和恢复,之后不自动补回。升级时将原收件箱的正常笔记一次性归入“随手记”,保留笔记 ID 并调整路径链接;有待恢复草稿或无法调整的链接时,整批保留原位并提示。在具体文件夹中新建归入当前文件夹;新建或导入未指定文件夹时显示为“未分类”,可在“全部笔记”中找到,不会隐式重建“随手记”。 - 正文左右各保留 48px 留白,笔记名称与正文左对齐;块操作按钮放在左侧留白内,与正文相隔 8px,并至少离开编辑区边界 12px。 - 正文上方提供独立的“笔记名称”输入框,按 Enter 或“保存名称”提交;新建笔记的名称默认为“未命名笔记”,正文为空。名称保存在 Markdown 元数据的 `title` 字段中,修改名称不改写正文标题,也不将名称列入正文大纲。正文标题仍通过格式工具或 Markdown 设置。 -- 笔记菜单包含置顶、收藏、移动、修改笔记名称、笔记信息、分享与导出、创建副本和最近删除。分享与导出以二级菜单提供当前笔记的 PDF、Markdown、HTML 和打印。自动保存不再占用普通笔记菜单的首位。 +- 唯一“更多”合并当前笔记和编辑工具:改名、信息、分享与导出、源码/正文切换、大纲、图片属性、代码语言与复制、附件管理。置顶、收藏、移动、副本和删除保留在列表/画廊右键菜单。分享与导出仍提供 PDF、Markdown、HTML 和打印;外部文件额外显示保存与导入。 - 列表右键菜单沿用邮件列表的定位:在鼠标点击位置弹出无箭头 LiquidPopover,只在接近窗口边缘时收进可见区域。笔记、子文件夹及最近删除列表使用同一规则;键盘打开笔记行菜单时以当前行中点作为位置,工具栏按钮仍就近弹出。 - 全部菜单与浮动表单采用统一触发规则:右键无箭头,按钮下方居中并指向按钮,后续菜单继承最初入口;空间不足时向上调整。完整范围和检查边界见 [菜单与浮动表单](popover-interactions.md)。 - 标签、关联和历史是当前笔记的信息,保留在“笔记信息”;模板管理从这套标签页中移出。 @@ -17,17 +17,21 @@ - 文件夹标题显示分类名称,不将完整分类路径当作页面标题。选择移动/导入目标时仍保留层级以区分同名分类。 - 空笔记空间提供次级“导入已有笔记”引导;普通使用时导入从设置进入。启动文案不再叫“打开我的笔记”。 +## 列表与画廊 + +浏览栏上方的“更多”菜单提供“列表视图”和“画廊视图”,显示当前选项。全部笔记和各个文件夹分别记住偏好;文件夹中的子文件夹同步以列表或卡片呈现,保持键盘导航、多选、右键与拖放。列表保留时间分组;画廊为紧凑文字卡片,显示标题、最多两行摘要、一行日期及置顶/收藏状态。卡片高度 104px,行列间距 12px,默认浏览栏宽 448px,至少保持两列。两种视图共用笔记模型、排序、多选、右键和拖放归类,切换保留游标与选择。画廊宽度独立保存,回收站仍用列表。方向键移动游标,画廊上下键跨行,Enter 打开。 + ## 设置 -设置位于顶部,快捷键 Ctrl+,;采用公共 LiquidDialog、LiquidSegmentedControl、SettingsGroup、SettingsItem 和既有控件,无独立自绘弹窗背景。 +设置位于左侧栏底部,使用普通导航按钮,快捷键 Ctrl+,;采用公共 LiquidDialog、LiquidSegmentedControl、SettingsGroup、SettingsItem 和既有控件,无独立自绘弹窗背景。 | 分区 | 内容 | | --- | --- | | 通用 | 阅读主题、正文字号、减少透明/动画、自动保存说明、快捷键 | -| 数据 | 笔记导入、整夹收集、导入记录、旧笔记迁移;全部笔记导出;完整备份恢复 | -| 高级 | 模板、视图、批量标签重命名、恢复草稿;临时编辑外部 Markdown、当前笔记源码 | +| 笔记管理 | 模板、保存搜索为智能视图、批量标签重命名、恢复草稿 | +| 数据 | 笔记导入、整夹收集、导入记录、旧笔记迁移;全部笔记导出;完整备份恢复;临时编辑外部 Markdown | -选择一项管理操作后先退出设置,再打开相应表单;不在设置上层连续堆叠操作窗口。设置正文可滚动,底部完成按钮固定,键盘聚焦到下方控件时跟随滚动。 +三个页签保持相同高度,按窗口可用空间缩小。选择管理操作后等待设置关闭动画完成,再打开相应表单;不在设置上层连续堆叠操作窗口。设置正文可滚动,底部完成按钮固定,键盘聚焦到下方控件时跟随滚动。 保留 Ctrl+O 的外部 Markdown 快捷操作;新增 Ctrl+Shift+I 导入。外部文件不加入聚合,只在明确导入后纳管;其身份和保存动作仅在实际打开外部文件时出现。 diff --git a/qml/EditorTools.qml b/qml/EditorTools.qml index cf1aba4..263f8d9 100644 --- a/qml/EditorTools.qml +++ b/qml/EditorTools.qml @@ -13,7 +13,7 @@ Item { readonly property real outlineWidth: outlineVisible && width > 600 ? 200 : 0 readonly property var state: source.editorState readonly property bool editable: !source.readOnly && !session.composing - readonly property bool popupActive: slashMenu.visible || blockMenu.visible || toolsMenu.visible || mathPanel.visible || codePanel.visible || imagePanel.visible || helpPanel.visible || attachmentsPanel.visible || outlinePanel.visible + readonly property bool popupActive: slashMenu.visible || blockMenu.visible || mathPanel.visible || codePanel.visible || imagePanel.visible || attachmentsPanel.visible || outlinePanel.visible property var block: ({}) property var spellSuggestions: [] property int documentEpoch: -1 @@ -32,7 +32,7 @@ Item { if (restore) source.editor.forceActiveFocus() } function previewDrag() { - if (dragInside) post("previewBlockMove", {pos: pressedBlock.pos, y: dragY}) + if (dragInside) post("previewBlockMove", {pos: pressedBlock.pos, x: dragX, y: dragY, offsetY: handlePointer.startY - pressedBlock.y}) else post("cancelBlockMove") } function closeSlash() { slashMenu.suppressOnClose = false; slashMenu.close() } @@ -42,8 +42,16 @@ Item { else if (originMenu) outlinePanel.openFromMenu(originMenu) else outlinePanel.openFromButton(anchor) } - function showShortcuts(anchor) { helpPanel.openFromButton(anchor) } - function openMenu(anchor) { toolsMenu.openFromButton(anchor) } + function openTool(name, originMenu) { + if (name === "math") openMath(originMenu) + else if (name === "image") openImage(originMenu) + else if (name === "code") openCode(originMenu) + else if (name === "outline") toggleOutline(null, originMenu) + else if (name === "attachments") { + controller.inspectAttachments() + attachmentsPanel.openFromMenu(originMenu) + } + } function openContext(x, y, suggestions) { pendingContext = {x: x, y: y, suggestions: suggestions || [], request: ++contextSequence} post("contextBlock", {x: x, y: y, request: contextSequence}) @@ -68,7 +76,7 @@ Item { function uiChanged() { if (state.epoch !== undefined && documentEpoch !== state.epoch) { documentEpoch = state.epoch - closeSlash(); blockMenu.close(); toolsMenu.close(); mathPanel.close(); codePanel.close(); imagePanel.close(); helpPanel.close(); attachmentsPanel.close(); outlinePanel.close() + closeSlash(); blockMenu.close(); mathPanel.close(); codePanel.close(); imagePanel.close(); attachmentsPanel.close(); outlinePanel.close() dragging = false; pressedBlock = null; pendingContext = null } if (pendingContext && state.context && state.context.request === pendingContext.request) { @@ -109,18 +117,23 @@ Item { anchors.fill: parent id: handlePointer cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + hoverEnabled: true + preventStealing: true + property real startX: 0 property real startY: 0 property int startPos: 0 property bool gestureCanceled: false onPressed: function(mouse) { gestureCanceled = false root.pressedBlock = root.state.block - startY = mapToItem(root, mouse.x, mouse.y).y; startPos = root.pressedBlock.pos; root.dragging = false + const point = mapToItem(root, mouse.x, mouse.y) + startX = point.x; startY = point.y; startPos = root.pressedBlock.pos; root.dragging = false } onPositionChanged: function(mouse) { if (!pressed || gestureCanceled) return const point = mapToItem(root, mouse.x, mouse.y) - if (!root.dragging && Math.abs(point.y - startY) > 6) { + if (!root.dragging && Math.hypot(point.x - startX, point.y - startY) > 6) { + root.post("selectBlock", {pos: startPos, focus: false}) handle.forceActiveFocus(Qt.MouseFocusReason) root.dragging = true } @@ -142,7 +155,12 @@ Item { } onCanceled: root.cancelDrag(true) } - KosToolTip { target: handle; text: "拖动移动块,点击打开块操作" } + KosToolTip { + target: handle; text: "拖动移动块,点击打开块操作" + delay: 1400; showOnFocus: false + hovered: handlePointer.containsMouse + when: !handlePointer.pressed && !root.dragging && !root.popupActive + } } Timer { interval: 40; repeat: true @@ -245,17 +263,6 @@ Item { onObjectRemoved: function(index, object) { blockMenu.removeItem(object) } } } - NotesPopover { - id: toolsMenu; sourceItem: root.sceneItem - LiquidPopoverItem { text: "大纲 / 折叠"; onTriggered: root.toggleOutline(null, toolsMenu) } - LiquidPopoverItem { text: "插入自动目录"; enabled: root.editable; onTriggered: root.post("insertToc") } - LiquidPopoverItem { text: "插入图片或附件…"; enabled: root.editable && !root.controller.externalDocument; onTriggered: root.post("chooseAttachment") } - LiquidPopoverItem { text: "图片属性…"; enabled: !!root.state.image; onTriggered: root.openImage(toolsMenu) } - LiquidPopoverItem { text: "附件管理…"; enabled: !root.controller.externalDocument; onTriggered: { root.controller.inspectAttachments(); attachmentsPanel.openFromMenu(toolsMenu) } } - LiquidPopoverItem { text: "数学公式…"; enabled: root.editable; onTriggered: root.openMath(toolsMenu) } - LiquidPopoverItem { text: "插入脚注"; enabled: root.editable; onTriggered: root.post("footnote") } - LiquidPopoverItem { text: "代码语言与复制…"; enabled: !!root.state.code; onTriggered: root.openCode(toolsMenu) } - } NotesContentPopover { id: mathPanel; sourceItem: root.sceneItem; preferredWidth: 370 onOpened: formula.forceActiveFocus() @@ -326,14 +333,4 @@ Item { Label { Layout.fillWidth: true; text: "只回收未被笔记引用的附件,可在附件回收区恢复。"; wrapMode: Text.WordWrap; color: AppTheme.mutedText; font.pixelSize: 11 } } } - NotesContentPopover { - id: helpPanel; sourceItem: root.sceneItem; preferredWidth: 370 - contentItem: ColumnLayout { - Label { text: "正文快捷键"; font.bold: true; color: AppTheme.text } - Repeater { - model: ["/ 插入与转换内容块", "Ctrl+B / Ctrl+I 粗体 / 斜体", "Ctrl+Shift+X 删除线", "Ctrl+E 行内代码", "Ctrl+Z / Ctrl+Shift+Z 撤销 / 重做", "Ctrl+F / Ctrl+H 查找 / 替换", "Alt+Shift+↑ / ↓ 移动块", "Enter / Shift+Enter 分段 / 段内换行", "Tab / Shift+Tab 列表缩进、表格导航", "Ctrl+Enter 退出表格", "Ctrl+点击链接 打开链接", "大纲左右键 / 右键 折叠与展开"] - Label { required property string modelData; text: modelData; color: AppTheme.text; font.pixelSize: 12; topPadding: 2; bottomPadding: 2 } - } - } - } } diff --git a/qml/FolderBrowser.qml b/qml/FolderBrowser.qml new file mode 100644 index 0000000..954fe8d --- /dev/null +++ b/qml/FolderBrowser.qml @@ -0,0 +1,74 @@ +import QtQuick +import QtQuick.Controls + +GridView { + id: root + required property var organization + property string viewMode: "list" + readonly property bool gallery: viewMode === "gallery" + readonly property int columns: gallery ? Math.max(2, Math.floor(width / 196)) : 1 + property string cursorKey: "" + objectName: "childFolderBrowser" + cellWidth: width / columns + cellHeight: gallery ? 104 : 62 + clip: true + activeFocusOnTab: visible && count > 0 + keyNavigationEnabled: false + Accessible.name: gallery ? "子文件夹画廊" : "子文件夹列表" + + function keyAt(row) { return row >= 0 && row < count ? String(model[row]) : "" } + function focusCurrent() { + if (!count) return + if (currentIndex < 0) currentIndex = 0 + cursorKey = keyAt(currentIndex) + positionViewAtIndex(currentIndex, GridView.Contain) + if (currentItem) currentItem.forceActiveFocus(Qt.TabFocusReason) + } + function restoreCursor() { + let row = -1 + for (let i = 0; i < count; ++i) if (keyAt(i) === cursorKey) { row = i; break } + currentIndex = row >= 0 ? row : count ? 0 : -1 + cursorKey = keyAt(currentIndex) + if (activeFocus) focusCurrent() + } + function handleKey(event) { + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { + if (!organization.folderSelectionMode) organization.beginSelection("folders") + organization.selectAll(); event.accepted = true; return + } + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && currentIndex >= 0) { + if (!organization.folderSelectionMode) organization.beginSelection("folders") + organization.toggleFolder(keyAt(currentIndex)); event.accepted = true; return + } + if (event.key === Qt.Key_Escape && organization.selectionMode) { organization.clearSelection(); event.accepted = true; return } + if (currentItem && (event.key === Qt.Key_Menu || event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier)) { + currentItem.openContext(); event.accepted = true; return + } + if (event.modifiers !== Qt.NoModifier || !count) return + let row = currentIndex + if (event.key === Qt.Key_Down) row += columns + else if (event.key === Qt.Key_Up) row -= columns + else if (gallery && event.key === Qt.Key_Right) row += 1 + else if (gallery && event.key === Qt.Key_Left) row -= 1 + else if (event.key === Qt.Key_Home) row = 0 + else if (event.key === Qt.Key_End) row = count - 1 + else if ([Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space].includes(event.key)) { + if (currentItem && !event.isAutoRepeat) currentItem.activate() + event.accepted = true; return + } else return + currentIndex = Math.max(0, Math.min(count - 1, row)) + focusCurrent(); event.accepted = true + } + onViewModeChanged: { + const hadFocus = activeFocus || !!currentItem && currentItem.activeFocus + Qt.callLater(function() { + forceLayout() + restoreCursor() + if (hadFocus) focusCurrent() + }) + } + onModelChanged: Qt.callLater(restoreCursor) + onActiveFocusChanged: if (activeFocus) Qt.callLater(function() { if (root.activeFocus) root.focusCurrent() }) + Keys.onPressed: event => handleKey(event) + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } +} diff --git a/qml/Main.qml b/qml/Main.qml index d6817ce..6e35e1c 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -11,7 +11,17 @@ ApplicationWindow { required property var controller width: controller.preference("width", tokens.windowWidth) height: controller.preference("height", tokens.windowHeight) - minimumWidth: 540; minimumHeight: 480 + readonly property int editorMinimumWidth: 720 + readonly property real browserMinimumWidth: 240 + minimumWidth: editorMinimumWidth + minimumHeight: 480 + // Wayland compositor scripts may bypass the advertised minimum; keep the + // editor no narrower than the full toolbar in every case. + onWidthChanged: { + if (width < editorMinimumWidth) width = editorMinimumWidth + if (width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth + || width < editorMinimumWidth + browserMinimumWidth) listHidden = false + } visible: true title: qsTr("笔记") flags: Qt.Window | Qt.FramelessWindowHint @@ -23,15 +33,16 @@ ApplicationWindow { function editNote() { Qt.callLater(function() { source.editor.forceActiveFocus() }) } property bool sidebarRequested: controller.preference("sidebar", true) property bool listRequested: false + property bool listHidden: false + property bool compactSidebarRequested: false property bool trashView: false readonly property string listTitle: controller.query && !root.trashView ? "搜索结果" : root.trashView ? "最近删除" : controller.filter === "favorite" ? "收藏" : controller.filter === "inbox" ? "未分类" : controller.filter.startsWith("import:") ? "本次导入" : controller.filter.startsWith("folder:") ? controller.filter.slice(7).split("/").pop() : "全部笔记" function showTrash() { browseCategory(controller.filter); organizer.clearSelection(); trashView = true; controller.inspectRecovery() } property string activeLibrary: controller.library onActiveLibraryChanged: trashView = false - property bool compactSidebarRequested: false function toggleNavigation() { - if (width >= 1000) sidebarRequested = !sidebarRequested - else if (width >= 800) compactSidebarRequested = !compactSidebarRequested + if (width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth) sidebarRequested = !sidebarRequested + else if (width >= editorMinimumWidth + sidebarWidth) compactSidebarRequested = !compactSidebarRequested else listRequested = !showList } readonly property var topLevelFolders: Array.from(controller.folders).filter(p => !p.includes("/")) @@ -43,7 +54,7 @@ ApplicationWindow { const parent = organizer.parentFolder(currentFolder) browseCategory(parent ? "folder:" + parent : "all") } - function revealList() { trashView = false; compactSidebarRequested = false; listRequested = true } + function revealList() { trashView = false; listRequested = true; listHidden = false; compactSidebarRequested = false } function noteLocation(path) { const parts = String(path || "").split("/"); parts.pop(); return parts.length ? parts.join(" › ") : "未分类" } // Presentation-only cleanup: the repository remains the source of Markdown. function summaryText(value) { @@ -60,11 +71,31 @@ ApplicationWindow { } property real sidebarWidth: controller.preference("sidebarWidth", tokens.sidebarWidth) property real listWidth: controller.preference("listWidth", tokens.listWidth) + property int viewPreferenceRevision: 0 + readonly property string viewPreferenceKey: "noteViewMode/" + encodeURIComponent(controller.library) + "/" + encodeURIComponent(currentFolder ? "folder:" + currentFolder : "all") + readonly property string noteViewMode: { + viewPreferenceRevision + return controller.preference(viewPreferenceKey, controller.preference("noteViewMode", "list")) === "gallery" ? "gallery" : "list" + } + function setNoteViewMode(mode) { + controller.setPreference(viewPreferenceKey, mode) + if (!currentFolder) controller.setPreference("noteViewMode", mode) + ++viewPreferenceRevision + } + property real galleryWidth: controller.preference("galleryWidth", 448) + readonly property bool gallery: noteViewMode === "gallery" && !trashView + readonly property real browserWidth: Math.min(root.gallery ? Math.max(416, root.galleryWidth) : root.listWidth, + Math.max(root.browserMinimumWidth, root.width - (root.showSidebar ? root.sidebarWidth : 0) - root.editorMinimumWidth)) readonly property bool hasLibrary: controller.library.length > 0 - readonly property bool showSidebar: hasLibrary && (width >= 1000 ? sidebarRequested : width >= 800 && compactSidebarRequested) - readonly property bool showList: hasLibrary && !(width < 1000 && showSidebar) && (width >= 800 || listRequested || (trashView ? !recycleBin.currentEntry : !controller.path)) - readonly property real editorX: (showSidebar ? sidebarWidth : 0) + (showList ? (width < 800 ? width : listWidth) : 0) - readonly property bool popupsOpen: (source.popupActive || knowledgePanel.popupActive || smartDialog.visible || missingLinkDialog.visible) || libraryActions.popupActive || organizer.popupActive || recycleBin.popupActive || actionMenu.visible || outputMenu.visible || formatMenu.visible || tableMenu.visible || linkPanel.visible || paragraphMenu.visible || namePanel.visible || recoveryDialog.visible || discardDialog.visible || folderPanel.visible || notesSettings.visible || discardRecoveryDialog.visible + readonly property bool threeColumns: width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth + readonly property bool sidebarAloneFits: width >= editorMinimumWidth + sidebarWidth + readonly property bool showSidebar: hasLibrary && sidebarRequested && (threeColumns || compactSidebarRequested && sidebarAloneFits) + readonly property bool showList: hasLibrary && !listHidden && !(showSidebar && !threeColumns) && ( + width >= editorMinimumWidth + browserMinimumWidth + || (width < 800 && listRequested) + || (trashView ? !recycleBin.currentEntry : !controller.path)) + readonly property real editorX: (showSidebar ? sidebarWidth : 0) + (showList ? (width < 800 ? width : browserWidth) : 0) + readonly property bool popupsOpen: (source.popupActive || knowledgePanel.popupActive || smartDialog.visible || missingLinkDialog.visible) || libraryActions.popupActive || organizer.popupActive || recycleBin.popupActive || actionMenu.visible || outputMenu.visible || insertMenu.visible || shortcutsDialog.visible || formatMenu.visible || tableMenu.visible || linkPanel.visible || paragraphMenu.visible || namePanel.visible || recoveryDialog.visible || discardDialog.visible || folderPanel.visible || notesSettings.visible || discardRecoveryDialog.visible onClosing: function(close) { if (source.exporting) { close.accepted = false; source.notify("正在导出,请完成后再关闭窗口"); return } controller.setPreference("width", width); controller.setPreference("height", height) @@ -73,7 +104,9 @@ ApplicationWindow { onSidebarRequestedChanged: controller.setPreference("sidebar", sidebarRequested) onSidebarWidthChanged: controller.setPreference("sidebarWidth", sidebarWidth) onListWidthChanged: controller.setPreference("listWidth", listWidth) + onGalleryWidthChanged: controller.setPreference("galleryWidth", galleryWidth) Binding { target: AppTheme; property: "compactDesign"; value: true } + Binding { target: LiquidStyle; property: "intensityLevel"; value: "medium" } Component.onCompleted: { AppTheme.reduceTransparency = controller.preference("reduceTransparency", false) AppTheme.reduceMotion = controller.preference("reduceMotion", false) @@ -133,7 +166,6 @@ ApplicationWindow { function handleKey(event) { if (model === controller.noteModel && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organizer.selectAll(); event.accepted = true; return } if (model === controller.noteModel && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && currentIndex >= 0) { organizer.toggle(controller.notes[currentIndex].path); event.accepted = true; return } - if (keyboardList === childFolderList && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organizer.beginSelection("folders"); organizer.selectAll(); event.accepted = true; return } if (event.key === Qt.Key_Escape && organizer.selectionMode) { organizer.clearSelection(); event.accepted = true; return } if (model === controller.noteModel && currentItem && (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier))) { organizer.openMenu(currentItem, controller.notes[currentIndex].path) @@ -171,7 +203,6 @@ ApplicationWindow { onModelChanged: Qt.callLater(restoreCursor) Keys.onPressed: function(event) { handleKey(event) } } - Connections { target: controller; function onNotesChanged() { Qt.callLater(noteList.restoreCursor) } } // Internal drag payloads never expose disk URLs or move source files. component OrganizePointer: MouseArea { id: pointer @@ -297,7 +328,7 @@ ApplicationWindow { Timer { interval: 30; repeat: true; running: organizer.dragging onTriggered: { - const view = [compactFolderList, sidebarFolderList, childFolderList, noteList].find(candidate => { + const view = [compactFolderList, sidebarFolderList, childFolderList, noteList.activeView].find(candidate => { if (!candidate.visible) return false const point = organizePreview.mapToItem(candidate, 12, 16) return point.x >= 0 && point.x <= candidate.width && point.y >= -16 && point.y <= candidate.height + 16 @@ -313,7 +344,7 @@ ApplicationWindow { component ToolbarAction: LiquidActionButton { property string help: "" iconOnly: true; toolbarAction: true - materialForm: false; backdrop: readingBackdrop + materialForm: false; backdrop: readingBackdrop; liquidIntensity: "medium" focusPolicy: Qt.TabFocus Accessible.name: help KosToolTip { target: parent; backdrop: readingBackdrop; text: parent.help } @@ -322,9 +353,25 @@ ApplicationWindow { Layout.minimumHeight: implicitHeight Layout.maximumHeight: implicitHeight } - component ListNavigationActions: LiquidActionButtonGroup { - id: listActions + component ToolbarActionGroup: LiquidActionButtonGroup { + id: toolbarGroup materialForm: false; backdrop: readingBackdrop + dividerColor: "transparent" + Repeater { + model: toolbarGroup.icons.length + Rectangle { + required property int index + x: index * toolbarGroup.buttonSize + y: (toolbarGroup.height - height) / 2 + width: 1; height: 12 + color: AppTheme.border + opacity: 0.65 + visible: index > 0 + } + } + } + component ListNavigationActions: ToolbarActionGroup { + id: listActions icons: root.currentFolder ? ["back", "more"] : ["more"] labels: root.currentFolder ? ["返回" + (organizer.parentFolder(root.currentFolder) ? organizer.parentFolder(root.currentFolder).split("/").pop() : "全部笔记"), "列表操作"] @@ -442,11 +489,12 @@ ApplicationWindow { onDoubleClicked: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() } RowLayout { - x: 14; width: root.showSidebar ? root.sidebarWidth - 26 : 152 + x: 14; width: root.showSidebar ? root.sidebarWidth - 26 : LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter; spacing: 8 Label { objectName: "notesTitle" - text: root.width < 800 && root.showList ? root.listTitle : root.title; color: AppTheme.text + visible: root.showSidebar + text: root.title; color: AppTheme.text font.pixelSize: 22; font.weight: Font.Bold Layout.fillWidth: true; Layout.preferredHeight: 28 verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight @@ -465,15 +513,15 @@ ApplicationWindow { background: LiquidActionButton { enabled: false; Accessible.ignored: true materialForm: false; backdrop: readingBackdrop - liquidIntensity: "strong" + liquidIntensity: "medium" } onClicked: root.toggleNavigation() } } RowLayout { visible: root.showList && root.width >= 800 - x: root.showSidebar ? root.sidebarWidth + 18 : 178 - width: root.showSidebar ? root.listWidth - 36 : Math.max(80, root.listWidth - 190) + x: root.showSidebar ? root.sidebarWidth + 18 : 54 + width: root.showSidebar ? root.browserWidth - 36 : Math.max(80, root.browserWidth - 66) anchors.verticalCenter: parent.verticalCenter Label { Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 0 @@ -491,19 +539,17 @@ ApplicationWindow { Item { id: headerActions visible: root.hasLibrary - x: root.editorX < root.width ? Math.max(178, root.editorX + 12) : 178 + x: root.editorX < root.width ? Math.max(root.showSidebar ? 178 : 54, root.editorX + 12) : (root.showSidebar ? 178 : 54) anchors.verticalCenter: parent.verticalCenter readonly property real controlsLeft: windowControls.x + (windowControls.buttonLayout.length ? windowControls.buttonLayout[0].x : 0) width: Math.max(0, controlsLeft - 10 - x); height: LiquidStyle.toolbarButtonHeight - readonly property bool wideSearch: root.width >= 1320 && width >= noteActions.implicitWidth + editorActions.implicitWidth + LiquidStyle.toolbarButtonHeight + 210 - readonly property bool searchShown: wideSearch || root.searchOpen || controller.query.length > 0 - readonly property bool searching: !wideSearch && searchShown + readonly property bool searchShown: root.searchOpen || controller.query.length > 0 + readonly property bool searching: searchShown Row { id: noteActions visible: !headerActions.searching height: parent.height; spacing: 8 HeaderAction { objectName: "newNoteButton"; iconName: "editRounded"; help: "新建笔记 · Ctrl+N"; enabled: !controller.busy; onClicked: root.newNote() } - HeaderAction { id: moreButton; enabled: !!controller.path; iconName: "more"; help: "更多笔记操作"; onClicked: actionMenu.openFromButton(moreButton) } } Flickable { id: editorActionsViewport @@ -542,48 +588,64 @@ ApplicationWindow { editorActionsViewport.contentX = Math.max(0, Math.min(editorActionsViewport.contentWidth - editorActionsViewport.width, desired)) } } - LiquidActionButtonGroup { + ToolbarActionGroup { id: editorActions objectName: "bodyEditingButtonGroup" height: LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter clip: false materialForm: false; backdrop: readingBackdrop - icons: session.sourceMode ? ["undo", "forward", ""] : ["undo", "forward", "", "", "", "", "reminder", "grid", "more"] - labels: session.sourceMode ? [controller.undoText, controller.redoText, "返回正文"] - : [controller.undoText, controller.redoText, "段落样式", "粗体 · Ctrl+B", "斜体 · Ctrl+I", "删除线 · Ctrl+Shift+X", "列表与插入", "表格与行列操作", "更多编辑工具"] - enabledActions: session.sourceMode - ? [!root.popupsOpen && controller.canUndo, !root.popupsOpen && controller.canRedo, source.visible && !controller.busy && !session.composing] - : [!root.popupsOpen && controller.canUndo, !root.popupsOpen && controller.canRedo, root.canFormat, root.canFormat, root.canFormat, root.canFormat, root.canFormat, root.canFormat, source.visible && !source.exporting] + readonly property var actions: [ + {id: "undo", icon: "undo", label: controller.undoText, enabled: !root.popupsOpen && controller.canUndo}, + {id: "redo", icon: "redo", label: controller.redoText, enabled: !root.popupsOpen && controller.canRedo} + ].concat(session.sourceMode ? [ + {id: "sourceMode", icon: "", label: "返回正文", enabled: source.visible && !controller.busy && !session.composing} + ] : [ + {id: "paragraph", icon: "", label: "段落样式", enabled: root.canFormat}, + {id: "bold", icon: "bold", label: "粗体 · Ctrl+B", enabled: root.canFormat}, + {id: "italic", icon: "italic", label: "斜体 · Ctrl+I", enabled: root.canFormat}, + {id: "strike", icon: "strikethrough", label: "删除线 · Ctrl+Shift+X", enabled: root.canFormat}, + {id: "code", icon: "code", label: "行内代码 · Ctrl+E", enabled: root.canFormat}, + {id: "quote", icon: "text_quote", label: "引用", enabled: root.canFormat}, + {id: "codeblock", icon: "doc_plaintext", label: "代码块", enabled: root.canFormat}, + {id: "link", icon: "link", label: "添加链接", enabled: root.canFormat}, + {id: "task", icon: "checkmark_square", label: "任务清单", enabled: root.canFormat}, + {id: "list", icon: "list_bullet", label: "列表", enabled: root.canFormat}, + {id: "table", icon: "table", label: "表格与行列操作", enabled: root.canFormat}, + {id: "insert", icon: "plus", label: "插入", enabled: root.canFormat} + ]).concat([{id: "more", icon: "more", label: "更多", enabled: !!controller.path && !controller.busy}]) + icons: actions.map(action => action.icon) + labels: actions.map(action => action.label) + enabledActions: actions.map(action => action.enabled) + objectNames: actions.map(action => "editorAction_" + action.id) + function actionX(id) { return Math.max(0, actions.findIndex(action => action.id === id)) * buttonSize } onTriggered: function(index) { - if (index === 0) controller.undo() - else if (index === 1) controller.redo() - else if (session.sourceMode) session.execute("sourceMode") - else if (index === 2) paragraphMenu.openFromButton(paragraphButton) - else if (index <= 5) root.applyFormat(["bold", "italic", "strike"][index - 3]) - else if (index === 6) formatMenu.openFromButton(insertButton) - else if (index === 7) tableMenu.openFromButton(tableButton) - else source.openTools(editorToolsButton) + const action = actions[index].id + if (action === "undo") controller.undo() + else if (action === "redo") controller.redo() + else if (action === "sourceMode") session.execute("sourceMode") + else if (action === "paragraph") paragraphMenu.openFromButton(paragraphButton) + else if (action === "list") formatMenu.openFromButton(listButton) + else if (action === "table") tableMenu.openFromButton(tableButton) + else if (action === "insert") insertMenu.openFromButton(insertButton) + else if (action === "link") linkPanel.openFromButton(linkButton) + else if (action === "more") actionMenu.openFromButton(moreButton) + else root.applyFormat(action) } - // Text marks fill the cells missing from the shared icon catalogue. - Repeater { - model: session.sourceMode ? ["正文"] : [session.headingLevel ? "H" + session.headingLevel : "正文", "B", "I", "S"] - Text { - required property string modelData - required property int index - x: (index + 2) * editorActions.buttonSize - width: editorActions.buttonSize; height: editorActions.buttonSize - text: modelData; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter - color: editorActions.enabledActions[index + 2] ? AppTheme.text : AppTheme.disabledText - font.pixelSize: index === 0 ? 10 : 14 - font.bold: index === 1; font.italic: index === 2; font.strikeout: index === 3 - Accessible.ignored: true - } + Text { + x: editorActions.actionX(session.sourceMode ? "sourceMode" : "paragraph") + width: editorActions.buttonSize; height: editorActions.buttonSize + text: !session.sourceMode && session.headingLevel ? "H" + session.headingLevel : "正文" + horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter + color: editorActions.enabledActions[2] ? AppTheme.text : AppTheme.disabledText + font.pixelSize: 10; Accessible.ignored: true } - Item { id: paragraphButton; x: 2 * editorActions.buttonSize; width: editorActions.buttonSize; height: width } - Item { id: insertButton; x: 6 * editorActions.buttonSize; width: editorActions.buttonSize; height: width } - Item { id: tableButton; x: 7 * editorActions.buttonSize; width: editorActions.buttonSize; height: width } - Item { id: editorToolsButton; x: 8 * editorActions.buttonSize; width: editorActions.buttonSize; height: width } + Item { id: paragraphButton; x: editorActions.actionX("paragraph"); width: editorActions.buttonSize; height: width } + Item { id: listButton; x: editorActions.actionX("list"); width: editorActions.buttonSize; height: width } + Item { id: tableButton; x: editorActions.actionX("table"); width: editorActions.buttonSize; height: width } + Item { id: insertButton; x: editorActions.actionX("insert"); width: editorActions.buttonSize; height: width } + Item { id: linkButton; x: editorActions.actionX("link"); width: editorActions.buttonSize; height: width } + Item { id: moreButton; x: editorActions.actionX("more"); width: editorActions.buttonSize; height: width } } } RowLayout { @@ -591,7 +653,6 @@ ApplicationWindow { anchors.right: parent.right width: headerActions.searching ? parent.width : implicitWidth height: parent.height; spacing: 10 - HeaderAction { iconName: "settings"; help: "设置 · Ctrl+,"; visible: !headerActions.searching; onClicked: notesSettings.show(0) } Item { visible: headerActions.searchShown Layout.preferredWidth: 160; Layout.fillWidth: headerActions.searching @@ -610,7 +671,7 @@ ApplicationWindow { } onTextEdited: if (!inputMethodComposing) root.updateGlobalQuery(text) onInputMethodComposingChanged: if (!inputMethodComposing && text !== controller.query) root.updateGlobalQuery(text) - onActiveFocusChanged: if (!activeFocus && !headerActions.wideSearch && text.length === 0 && root.searchOpen) root.closeGlobalSearch() + onActiveFocusChanged: if (!activeFocus && text.length === 0 && root.searchOpen) root.closeGlobalSearch() Keys.onEscapePressed: function(event) { if (!inputMethodComposing) { root.closeGlobalSearch(); event.accepted = true } } Accessible.name: "搜索所有笔记" } @@ -736,20 +797,27 @@ ApplicationWindow { width: ListView.view.width; height: implicitHeight } } + NavigationRow { + objectName: "settingsButton" + Layout.fillWidth: true + iconName: "settings"; text: "设置" + Accessible.name: "设置 · Ctrl+," + onClicked: notesSettings.show(0) + } } } Rectangle { id: listColumn visible: root.showList x: root.showSidebar ? root.sidebarWidth : 0 - width: root.width < 800 ? root.width : root.listWidth; height: parent.height + width: root.width < 800 ? root.width : root.browserWidth; height: parent.height color: AppTheme.contentBackground Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: AppTheme.border; opacity: 0.5 } ColumnLayout { id: noteColumnLayout anchors.fill: parent; anchors.margins: 12; spacing: 12 RowLayout { - visible: !root.showSidebar; Layout.fillWidth: true + visible: !root.showSidebar && root.width < 800; Layout.fillWidth: true LiquidActionButton { text: "全部"; materialForm: true; onClicked: { root.browseCategory("all") } } LiquidActionButton { text: "收藏"; materialForm: true; onClicked: { root.browseCategory("favorite") } } LiquidActionButton { text: "最近删除"; materialForm: true; onClicked: root.showTrash() } @@ -806,12 +874,13 @@ ApplicationWindow { anchors.fill: parent; radius: 10 color: AppTheme.dark ? "#303032" : "#f7f7f8" } - KeyboardList { + FolderBrowser { id: childFolderList + organization: organizer + viewMode: root.noteViewMode visible: childFolderSection.visible && root.childFoldersExpanded anchors.fill: parent anchors.topMargin: 4; anchors.bottomMargin: 4 - clip: true; spacing: 0 model: root.childFolders Accessible.name: "子文件夹" delegate: ItemDelegate { @@ -824,8 +893,9 @@ ApplicationWindow { readonly property string summary: (details.createdUtc ? new Date(details.createdUtc).toLocaleDateString(Qt.locale(), "yyyy/M/d") + " 创建" : "创建日期未记录") + " · " + (details.noteCount || 0) + " 篇笔记" - width: childFolderList.width - height: contentItem.implicitHeight + topPadding + bottomPadding + readonly property bool card: childFolderList.gallery + width: childFolderList.cellWidth - (card ? 12 : 0) + height: childFolderList.cellHeight - (card ? 12 : 0) leftPadding: organizer.folderSelectionMode ? 36 : 12 rightPadding: 12; topPadding: 7; bottomPadding: 7 readonly property bool bulkSelected: organizer.selectedFolderPaths.includes(modelData) @@ -840,8 +910,9 @@ ApplicationWindow { background: Item { Rectangle { anchors.fill: parent; anchors.margins: 4 - radius: 7; border.width: 0 - color: childFolderRow.bulkSelected ? tokens.navigationSelected : childFolderRow.hovered ? AppTheme.controlBackground : "transparent" + radius: 7; border.width: childFolderRow.card && !childFolderRow.bulkSelected ? 1 : 0 + border.color: AppTheme.border + color: childFolderRow.bulkSelected ? tokens.navigationSelected : (childFolderRow.card || childFolderRow.hovered) ? AppTheme.controlBackground : "transparent" } } contentItem: Column { @@ -861,7 +932,7 @@ ApplicationWindow { elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: AppTheme.text } } - Label { width: parent.width; text: childFolderRow.summary; elide: Text.ElideRight; font.pixelSize: 12; color: AppTheme.mutedText } + Label { width: parent.width; text: childFolderRow.summary; elide: Text.ElideRight; wrapMode: childFolderRow.card ? Text.Wrap : Text.NoWrap; maximumLineCount: childFolderRow.card ? 2 : 1; font.pixelSize: 12; color: AppTheme.mutedText } } OrganizePointer { folderPath: childFolderRow.modelData; enabled: !controller.busy @@ -896,7 +967,6 @@ ApplicationWindow { y: Math.max(0, childFolderList.originY + childFolderList.contentHeight - (childFolderList.footerItem ? childFolderList.footerItem.height : 0) - childFolderList.contentY) width: childFolderList.width; height: Math.max(0, childFolderList.height - y) } - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } } } } @@ -907,98 +977,112 @@ ApplicationWindow { text: "笔记 · " + noteList.count color: AppTheme.mutedText; font.pixelSize: 11; font.weight: Font.Medium } - KeyboardList { + Label { + visible: !root.trashView && !childFolderSection.visible + Layout.fillWidth: true + text: noteList.count + " 篇笔记"; color: AppTheme.mutedText; font.pixelSize: 11 + } + NotesBrowser { id: noteList visible: !root.trashView - Layout.fillWidth: true; Layout.fillHeight: true; clip: true; spacing: 4 - model: controller.noteModel - Accessible.name: "笔记" + Layout.fillWidth: true; Layout.fillHeight: true + controller: root.controller; organization: organizer + viewMode: root.gallery ? "gallery" : "list" + noteDelegate: noteRowDelegate - delegate: ItemDelegate { - id: noteRow - required property var modelData - required property int index - readonly property string rowStatus: [modelData.favorite ? "收藏" : "", modelData.id === controller.selectedId && modelData.path === controller.path ? controller.status : modelData.readOnlyReason ? "只读" : ""].filter(Boolean).join(" · ") - focusPolicy: Qt.StrongFocus - activeFocusOnTab: false - Keys.onPressed: function(event) { ListView.view.handleKey(event) } - onActiveFocusChanged: if (activeFocus) { ListView.view.currentIndex = index; ListView.view.cursorKey = ListView.view.keyAt(index) } - function activate() { - if (organizer.noteSelectionMode) { organizer.toggle(modelData.path); return } - organizer.clearSelection() - organizer.selectionAnchor = modelData.path - controller.openNote(modelData.path, modelData.match === undefined ? -1 : modelData.match) - } - readonly property bool bulkSelected: organizer.selectedPaths.indexOf(modelData.path) >= 0 - readonly property string groupName: root.dateGroup(modelData) - readonly property bool startsGroup: !!groupName && (index === 0 || root.dateGroup(controller.notes[index - 1]) !== groupName) - // 32px heading plus a 4px gap before the note background. - readonly property int groupHeight: startsGroup ? 36 : 0 - readonly property int contentTopInset: startsGroup ? 8 : 12 - width: ListView.view.width; height: tokens.noteRowHeight + groupHeight - (12 - contentTopInset) - Label { - x: 6; y: 4; visible: noteRow.startsGroup; text: noteRow.groupName - font.pixelSize: 15; font.weight: Font.DemiBold; color: AppTheme.text - } - Rectangle { - x: 6; y: noteRow.groupHeight - 5; width: parent.width - 12; height: 1 - visible: noteRow.startsGroup - color: AppTheme.border; opacity: 0.6 - } - // Show selection controls only during an explicit multi-selection. - leftPadding: organizer.noteSelectionMode ? 36 : 12; topPadding: contentTopInset + groupHeight; bottomPadding: 12 - highlighted: organizer.noteSelectionMode ? bulkSelected : organizer.contextPath ? modelData.path === organizer.contextPath : (modelData.id === controller.selectedId && modelData.path === controller.path) - background: Rectangle { - y: noteRow.groupHeight; height: noteRow.height - y - radius: 10; color: noteRow.highlighted ? tokens.notesYellow : noteRow.hovered ? AppTheme.controlBackground : "transparent" - border.width: 0 - } - readonly property string thumbnail: "" - Image { - anchors.right: parent.right; anchors.rightMargin: 12 - y: noteRow.groupHeight + noteRow.contentTopInset - 2; width: 48; height: 48 - visible: !!noteRow.thumbnail; source: noteRow.thumbnail - fillMode: Image.PreserveAspectCrop; asynchronous: true; clip: true - sourceSize: Qt.size(96, 96) - } - rightPadding: thumbnail ? 70 : 12 - contentItem: Column { - spacing: 3 - Row { - width: parent.width; spacing: 6 - Label { width: parent.width - (favoriteMark.visible ? 20 : 0); text: (noteRow.modelData.pinned ? "置顶 · " : "") + noteRow.modelData.title; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: noteRow.highlighted ? "white" : AppTheme.text } - VectorIcon { id: favoriteMark; visible: !!noteRow.modelData.favorite; name: "heart"; size: 13; color: noteRow.highlighted ? "white" : AppTheme.mutedText } - } - Label { width: parent.width; text: new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "M/d") + " " + root.summaryText(noteRow.modelData.summary); textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 12; color: noteRow.highlighted ? "white" : AppTheme.mutedText } - } - OrganizePointer { - notePath: noteRow.modelData.path; noteTitle: noteRow.modelData.title; enabled: !controller.busy - onClickedItem: function(button, modifiers, position) { - noteRow.forceActiveFocus(Qt.MouseFocusReason) - if (button === Qt.RightButton) organizer.openMenu(noteRow, noteRow.modelData.path, position) - else if (!organizer.click(noteRow.modelData, noteRow.index, modifiers)) noteRow.activate() - } - } - NotesSelectionBox { - id: noteSelectionBox - visible: organizer.noteSelectionMode - x: 6; y: noteRow.groupHeight + noteRow.contentTopInset; width: 24; height: 24 - padding: 4 - checked: noteRow.bulkSelected; text: "" - Accessible.name: "选择笔记 " + noteRow.modelData.title + "," + root.noteLocation(noteRow.modelData.path) - onClicked: organizer.toggle(noteRow.modelData.path) - } - onClicked: activate() - Accessible.role: Accessible.ListItem - Accessible.focusable: true - Accessible.selected: organizer.noteSelectionMode ? bulkSelected : highlighted - Accessible.onPressAction: activate() - Accessible.name: modelData.title + "," + root.noteLocation(modelData.path) + (rowStatus ? ", " + rowStatus : "") - } Label { x: 12; y: Math.max(0, (parent.height - implicitHeight) / 2); width: parent.width - 24; wrapMode: Text.Wrap; horizontalAlignment: Text.AlignHCenter; visible: noteList.count === 0; text: controller.query ? "没有找到相关笔记" : controller.filter === "favorite" ? "收藏的笔记会显示在这里" : controller.filter.startsWith("folder:") ? "这个文件夹还没有笔记" : "从第一篇笔记开始"; color: AppTheme.mutedText; font.pixelSize: 13 } } } } + Component { + id: noteRowDelegate + ItemDelegate { + id: noteRow + readonly property bool card: GridView.view !== null + required property var modelData + required property int index + readonly property string rowStatus: [modelData.favorite ? "收藏" : "", modelData.id === controller.selectedId && modelData.path === controller.path ? controller.status : modelData.readOnlyReason ? "只读" : ""].filter(Boolean).join(" · ") + focusPolicy: Qt.StrongFocus + activeFocusOnTab: false + Keys.onPressed: function(event) { noteList.handleKey(event) } + onActiveFocusChanged: if (activeFocus) { noteList.setCursor(index) } + function activate() { + if (organizer.noteSelectionMode) { organizer.toggle(modelData.path); return } + organizer.clearSelection() + organizer.selectionAnchor = modelData.path + controller.openNote(modelData.path, modelData.match === undefined ? -1 : modelData.match) + } + readonly property bool bulkSelected: organizer.selectedPaths.indexOf(modelData.path) >= 0 + readonly property string groupName: root.dateGroup(modelData) + readonly property bool startsGroup: !noteRow.card && !!groupName && (index === 0 || root.dateGroup(controller.notes[index - 1]) !== groupName) + // 32px heading plus a 4px gap before the note background. + readonly property int groupHeight: startsGroup ? 36 : 0 + readonly property int contentTopInset: startsGroup ? 8 : 12 + width: noteRow.card ? GridView.view.cellWidth - 12 : noteList.width + height: noteRow.card ? GridView.view.cellHeight - 12 : tokens.noteRowHeight + groupHeight - (12 - contentTopInset) + Label { + x: 6; y: 4; visible: noteRow.startsGroup; text: noteRow.groupName + font.pixelSize: 15; font.weight: Font.DemiBold; color: AppTheme.text + } + Rectangle { + x: 6; y: noteRow.groupHeight - 5; width: parent.width - 12; height: 1 + visible: noteRow.startsGroup + color: AppTheme.border; opacity: 0.6 + } + // Show selection controls only during an explicit multi-selection. + leftPadding: organizer.noteSelectionMode ? 36 : 12; topPadding: contentTopInset + groupHeight; bottomPadding: 12 + highlighted: organizer.noteSelectionMode ? bulkSelected : organizer.contextPath ? modelData.path === organizer.contextPath : (modelData.id === controller.selectedId && modelData.path === controller.path) + background: Rectangle { + y: noteRow.groupHeight; height: noteRow.height - y + radius: 10; color: noteRow.highlighted ? tokens.notesYellow : (noteRow.card || noteRow.hovered) ? AppTheme.controlBackground : "transparent" + border.width: noteRow.card && !noteRow.highlighted ? 1 : 0 + border.color: AppTheme.border + } + readonly property string thumbnail: "" + Image { + anchors.right: parent.right; anchors.rightMargin: 12 + y: noteRow.groupHeight + noteRow.contentTopInset - 2; width: 48; height: 48 + visible: !!noteRow.thumbnail; source: noteRow.thumbnail + fillMode: Image.PreserveAspectCrop; asynchronous: true; clip: true + sourceSize: Qt.size(96, 96) + } + rightPadding: thumbnail ? 70 : 12 + contentItem: Column { + spacing: 3 + Row { + width: parent.width; spacing: 6 + Label { width: parent.width - (favoriteMark.visible ? 20 : 0); text: (noteRow.modelData.pinned ? "置顶 · " : "") + noteRow.modelData.title; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: noteRow.highlighted ? "white" : AppTheme.text } + VectorIcon { id: favoriteMark; visible: !!noteRow.modelData.favorite; name: "heart"; size: 13; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + } + Label { width: parent.width; text: noteRow.card ? root.summaryText(noteRow.modelData.summary) : new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "M/d") + " " + root.summaryText(noteRow.modelData.summary); textFormat: Text.PlainText; elide: Text.ElideRight; wrapMode: noteRow.card ? Text.Wrap : Text.NoWrap; maximumLineCount: noteRow.card ? 2 : 1; font.pixelSize: 12; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + Label { visible: noteRow.card; text: new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "yyyy/M/d"); font.pixelSize: 11; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + } + OrganizePointer { + notePath: noteRow.modelData.path; noteTitle: noteRow.modelData.title; enabled: !controller.busy + onClickedItem: function(button, modifiers, position) { + noteRow.forceActiveFocus(Qt.MouseFocusReason) + if (button === Qt.RightButton) organizer.openMenu(noteRow, noteRow.modelData.path, position) + else if (!organizer.click(noteRow.modelData, noteRow.index, modifiers)) noteRow.activate() + } + } + NotesSelectionBox { + id: noteSelectionBox + visible: organizer.noteSelectionMode + x: 6; y: noteRow.groupHeight + noteRow.contentTopInset; width: 24; height: 24 + padding: 4 + checked: noteRow.bulkSelected; text: "" + Accessible.name: "选择笔记 " + noteRow.modelData.title + "," + root.noteLocation(noteRow.modelData.path) + onClicked: organizer.toggle(noteRow.modelData.path) + } + onClicked: activate() + Accessible.role: Accessible.ListItem + Accessible.focusable: true + Accessible.selected: organizer.noteSelectionMode ? bulkSelected : highlighted + Accessible.onPressAction: activate() + Accessible.name: modelData.title + "," + root.noteLocation(modelData.path) + (rowStatus ? ", " + rowStatus : "") + } + } + RecycleBin { id: recycleBin visible: root.trashView && root.hasLibrary @@ -1021,8 +1105,15 @@ ApplicationWindow { MouseArea { visible: root.showList && root.width >= 800; x: root.editorX - 3; width: 6; height: parent.height; cursorShape: Qt.SplitHCursor property real startWidth: 0; property real startX: 0 - onPressed: function(mouse) { startWidth = root.listWidth; startX = mapToItem(root.contentItem, mouse.x, mouse.y).x } - onPositionChanged: function(mouse) { if (pressed) root.listWidth = Math.max(240, Math.min(340, startWidth + mapToItem(root.contentItem, mouse.x, mouse.y).x - startX)) } + onPressed: function(mouse) { startWidth = root.gallery ? root.galleryWidth : root.listWidth; startX = mapToItem(root.contentItem, mouse.x, mouse.y).x } + onPositionChanged: function(mouse) { if (pressed) { + const next = startWidth + mapToItem(root.contentItem, mouse.x, mouse.y).x - startX + const maximum = Math.max(root.browserMinimumWidth, root.width - (root.showSidebar ? root.sidebarWidth : 0) - root.editorMinimumWidth) + if (root.gallery) { + const limit = Math.min(800, maximum) + root.galleryWidth = Math.max(Math.min(416, limit), Math.min(limit, next)) + } else root.listWidth = Math.max(240, Math.min(340, maximum, next)) + } } } Item { id: editorChrome @@ -1144,20 +1235,23 @@ ApplicationWindow { } } NotesPopover { - id: formatMenu; anchorItem: insertButton; sourceItem: readingScene + id: formatMenu; anchorItem: listButton; sourceItem: readingScene onClosed: Qt.callLater(root.restoreEditorFocus) LiquidPopoverItem { text: "项目列表"; onTriggered: root.applyFormat("bullet") } LiquidPopoverItem { text: "编号列表"; onTriggered: root.applyFormat("ordered") } LiquidPopoverItem { text: "任务清单"; onTriggered: root.applyFormat("task") } LiquidPopoverItem { text: "勾选 / 取消勾选"; onTriggered: root.applyFormat("check") } - LiquidPopoverItem { text: "行内代码"; onTriggered: root.applyFormat("code") } - LiquidPopoverItem { text: "插入表格"; onTriggered: root.applyFormat("table") } - LiquidPopoverItem { text: "引用"; onTriggered: root.applyFormat("quote") } - LiquidPopoverItem { text: "代码块"; onTriggered: root.applyFormat("codeblock") } - LiquidPopoverItem { text: "分隔线"; onTriggered: root.applyFormat("rule") } - LiquidPopoverItem { text: "增加列表缩进"; onTriggered: root.applyFormat("indent") } - LiquidPopoverItem { text: "减少列表缩进"; onTriggered: root.applyFormat("outdent") } - LiquidPopoverItem { text: "添加链接…"; onTriggered: linkPanel.openFromMenu(formatMenu) } + LiquidPopoverItem { text: "增加列表缩进"; enabled: root.canFormat; onTriggered: root.applyFormat("indent") } + LiquidPopoverItem { text: "减少列表缩进"; enabled: root.canFormat; onTriggered: root.applyFormat("outdent") } + } + NotesPopover { + id: insertMenu; anchorItem: insertButton; sourceItem: readingScene + onClosed: Qt.callLater(root.restoreEditorFocus) + LiquidPopoverItem { text: "图片或附件…"; enabled: root.canFormat && !controller.externalDocument; onTriggered: source.action("chooseAttachment") } + LiquidPopoverItem { text: "数学公式…"; enabled: root.canFormat; onTriggered: source.openTool("math", insertMenu) } + LiquidPopoverItem { text: "脚注"; enabled: root.canFormat; onTriggered: source.action("footnote") } + LiquidPopoverItem { text: "自动目录"; enabled: root.canFormat; onTriggered: source.action("insertToc") } + LiquidPopoverItem { text: "分隔线"; enabled: root.canFormat; onTriggered: root.applyFormat("rule") } } NotesPopover { id: tableMenu; anchorItem: tableButton; sourceItem: readingScene @@ -1200,14 +1294,15 @@ ApplicationWindow { onClosed: Qt.callLater(root.restoreEditorFocus) LiquidPopoverItem { text: "保存外部文件"; visible: controller.externalDocument; height: visible ? implicitHeight : 0; enabled: controller.dirty && !controller.busy; onTriggered: controller.save() } LiquidPopoverItem { text: "导入为笔记…"; visible: controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !controller.busy; onTriggered: controller.importExternal() } - LiquidPopoverItem { text: root.currentNote.pinned ? "取消置顶" : "置顶"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: controller.organize(root.currentNote.pinned ? "unpin" : "pin", [controller.path]) } - LiquidPopoverItem { text: root.currentNote.favorite ? "取消收藏" : "收藏"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: controller.favorite() } - LiquidPopoverItem { text: "移动到文件夹…"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: organizer.moveNotes([controller.path], null, actionMenu) } LiquidPopoverItem { text: "修改笔记名称…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: root.askName("move", null, actionMenu) } LiquidPopoverItem { text: "笔记信息…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: knowledgePanel.open("properties") } LiquidPopoverItem { text: "分享与导出…"; enabled: !!controller.path && !controller.busy; onTriggered: outputMenu.openFromMenu(actionMenu) } - LiquidPopoverItem { text: "创建副本"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: controller.organize("duplicate", [controller.path]) } - LiquidPopoverItem { text: "删除笔记…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: organizer.deleteNotes([controller.path]) } + LiquidPopoverItem { text: session.sourceMode ? "返回正文" : "查看 Markdown 源码"; enabled: !!controller.path && !controller.busy && !session.composing && !session.webLoading; onTriggered: session.execute("sourceMode") } + MenuSeparator {} + LiquidPopoverItem { text: "大纲 / 折叠"; enabled: !session.sourceMode && source.webReady; onTriggered: source.openTool("outline", actionMenu) } + LiquidPopoverItem { text: "图片属性…"; enabled: !session.sourceMode && !!source.editorState.image; onTriggered: source.openTool("image", actionMenu) } + LiquidPopoverItem { text: "代码语言与复制…"; enabled: !session.sourceMode && !!source.editorState.code; onTriggered: source.openTool("code", actionMenu) } + LiquidPopoverItem { text: "附件管理…"; enabled: !session.sourceMode && !controller.externalDocument; onTriggered: source.openTool("attachments", actionMenu) } } NotesPopover { id: outputMenu; sourceItem: readingScene; anchorItem: moreButton @@ -1283,7 +1378,7 @@ ApplicationWindow { } } NotesSettings { - id: notesSettings; hostWindow: root; controller: root.controller; session: root.session; editorSource: source; sourceItem: readingScene + id: notesSettings; hostWindow: root; controller: root.controller; sourceItem: readingScene onActionRequested: function(action) { switch(action) { case "importFiles": libraryActions.importFiles(); break @@ -1299,7 +1394,38 @@ ApplicationWindow { case "recovery": root.showRecovery(); break case "external": libraryActions.openExternalFile(); break case "sourceMode": session.execute("sourceMode"); break - case "shortcuts": source.showShortcuts(moreButton); break + case "shortcuts": shortcutsDialog.open(); break + } + } + } + LiquidDialog { + id: shortcutsDialog + title: "键盘快捷键"; closeText: "完成"; standardButtons: Dialog.Close + sourceItem: readingScene; anchors.centerIn: parent + width: Math.min(620, root.width - 40); height: Math.min(580, root.height - 40) + contentItem: ScrollView { + id: shortcutScroll + clip: true; contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ColumnLayout { + width: shortcutScroll.availableWidth; spacing: 18 + Repeater { + model: [ + {title: "应用与笔记", rows: ["Ctrl+N 新建笔记", "Ctrl+S 保存", "Ctrl+O 临时编辑外部 Markdown", "Ctrl+Shift+I 导入 Markdown", "Ctrl+, 设置", "Ctrl+Shift+F 搜索全部笔记", "Ctrl+Shift+B 显示或隐藏导航", "Alt+← / → 前一篇 / 后一篇笔记"]}, + {title: "列表与画廊", rows: ["方向键 移动游标,Enter / 空格打开", "画廊 ↑ / ↓ 移动一行;← / → 移动一项", "Home / End 第一项 / 最后一项", "Ctrl+A 选择当前结果(最多 200 篇)", "Ctrl+空格 / Ctrl+点击 切换选中", "Shift+方向键 / Shift+点击 连续选择", "菜单键 / Shift+F10 当前笔记右键菜单", "Esc 取消多选或拖动"]}, + {title: "正文编辑", rows: ["/ 插入与转换内容块", "Ctrl+B / Ctrl+I 粗体 / 斜体", "Ctrl+Shift+X 删除线", "Ctrl+E 行内代码", "Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y 撤销 / 重做", "Ctrl+F / Ctrl+H 查找 / 替换", "Alt+Shift+↑ / ↓ 移动块", "Enter / Shift+Enter 分段 / 段内换行", "Tab / Shift+Tab 列表缩进、表格导航", "Ctrl+Enter 退出表格", "Ctrl+点击链接 打开链接", "大纲左右键 / 右键 折叠与展开"]}, + {title: "Markdown 源码", rows: ["Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y 撤销 / 重做", "Tab / Shift+Tab 增加 / 减少缩进", "使用工具栏「正文」或更多菜单返回正文"]} + ] + ColumnLayout { + required property var modelData + Layout.fillWidth: true; spacing: 6 + Label { text: parent.modelData.title; font.weight: Font.DemiBold; color: AppTheme.text } + Repeater { + model: parent.modelData.rows + Label { required property string modelData; Layout.fillWidth: true; text: modelData; wrapMode: Text.Wrap; color: AppTheme.text; font.pixelSize: 12 } + } + } + } } } } @@ -1311,6 +1437,8 @@ ApplicationWindow { id: organizer; anchors.fill: parent controller: root.controller; sceneItem: readingScene selectableFolders: root.childFolders + viewMode: root.noteViewMode + onViewModeRequested: mode => root.setNoteViewMode(mode) onFolderSelectionModeChanged: if (folderSelectionMode) root.childFoldersExpanded = true onSmartFolderRequested: smartDialog.open() onExportRequested: function(paths, folder) { libraryActions.exportSelection(paths, folder) } diff --git a/qml/NotesBrowser.qml b/qml/NotesBrowser.qml new file mode 100644 index 0000000..72b7080 --- /dev/null +++ b/qml/NotesBrowser.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + required property var controller + required property var organization + required property Component noteDelegate + property string viewMode: "list" + readonly property bool gallery: viewMode === "gallery" + readonly property var activeView: gallery ? grid : list + readonly property int count: controller.notes.length + readonly property int columns: Math.max(2, Math.floor(width / 196)) + property string cursorKey: "" + property string previousMode: "list" + property var positions: ({}) + + function keyAt(row) { + const note = controller.notes[row] + return note ? note.id + "\n" + note.path : "" + } + function indexOf(key) { + for (let i = 0; i < count; ++i) if (keyAt(i) === key) return i + return -1 + } + function setCursor(row) { + activeView.currentIndex = row + cursorKey = keyAt(row) + } + function focusCurrent() { + if (!count) return + if (activeView.currentIndex < 0) setCursor(0) + activeView.positionViewAtIndex(activeView.currentIndex, ListView.Contain) + if (activeView.currentItem) activeView.currentItem.forceActiveFocus(Qt.TabFocusReason) + } + function restoreCursor() { + const row = indexOf(cursorKey) + setCursor(row >= 0 ? row : count ? 0 : -1) + if (activeView.activeFocus) focusCurrent() + } + function positionViewAtBeginning() { + positions = ({}) + activeView.positionViewAtBeginning() + } + function switchView() { + const old = previousMode === "gallery" ? grid : list + const hadFocus = old.activeFocus || !!old.currentItem && old.currentItem.activeFocus + const first = old.indexAt(Math.max(1, old.width / 2), old.contentY + 10) + const saved = positions + saved[previousMode] = {key: keyAt(first >= 0 ? first : old.currentIndex), y: old.contentY} + positions = saved + previousMode = viewMode + Qt.callLater(function() { + restoreCursor() + const position = positions[viewMode] + const row = indexOf(position ? position.key : saved[old === grid ? "gallery" : "list"]?.key) + if (row >= 0) activeView.positionViewAtIndex(row, ListView.Beginning) + if (hadFocus) focusCurrent() + }) + } + function handleKey(event) { + const view = activeView, current = view.currentIndex + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organization.selectAll(); event.accepted = true; return } + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && current >= 0) { organization.toggle(controller.notes[current].path); event.accepted = true; return } + if (event.key === Qt.Key_Escape && organization.selectionMode) { organization.clearSelection(); event.accepted = true; return } + if (view.currentItem && (event.key === Qt.Key_Menu || event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier)) { + organization.openMenu(view.currentItem, controller.notes[current].path) + event.accepted = true; return + } + if (event.modifiers & ~(Qt.ShiftModifier | Qt.ControlModifier)) return + let row = current + const step = gallery ? columns : 1 + if (event.key === Qt.Key_Down) row += step + else if (event.key === Qt.Key_Up) row -= step + else if (gallery && event.key === Qt.Key_Right) row += 1 + else if (gallery && event.key === Qt.Key_Left) row -= 1 + else if (event.key === Qt.Key_Home) row = 0 + else if (event.key === Qt.Key_End) row = count - 1 + else if (event.modifiers === Qt.NoModifier && [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space].indexOf(event.key) >= 0) { + if (view.currentItem && !event.isAutoRepeat) view.currentItem.activate() + event.accepted = true; return + } else return + if (!count) return + row = Math.max(0, Math.min(count - 1, row)) + if (event.modifiers & Qt.ShiftModifier) { + if (!organization.selectionAnchor && current >= 0) organization.selectionAnchor = controller.notes[current].path + organization.click(controller.notes[row], row, event.modifiers) + } + setCursor(row); focusCurrent(); event.accepted = true + } + onViewModeChanged: switchView() + Connections { target: root.controller; function onNotesChanged() { Qt.callLater(root.restoreCursor) } } + ListView { + id: list + objectName: "notesListView" + anchors.fill: parent; visible: !root.gallery; clip: true; spacing: 4 + model: root.controller.noteModel + delegate: root.noteDelegate + activeFocusOnTab: visible && count > 0; keyNavigationEnabled: false + Accessible.name: "笔记列表" + Keys.onPressed: event => root.handleKey(event) + onActiveFocusChanged: if (activeFocus) Qt.callLater(root.focusCurrent) + ScrollBar.vertical: ScrollBar {} + } + GridView { + id: grid + objectName: "notesGalleryView" + anchors.fill: parent; visible: root.gallery; clip: true + cellWidth: width / root.columns; cellHeight: 116 + model: root.controller.noteModel + delegate: root.noteDelegate + activeFocusOnTab: visible && count > 0; keyNavigationEnabled: false + Accessible.name: "笔记画廊" + Keys.onPressed: event => root.handleKey(event) + onActiveFocusChanged: if (activeFocus) Qt.callLater(root.focusCurrent) + ScrollBar.vertical: ScrollBar {} + } +} diff --git a/qml/NotesOrganizer.qml b/qml/NotesOrganizer.qml index 734f08c..dcb4c80 100644 --- a/qml/NotesOrganizer.qml +++ b/qml/NotesOrganizer.qml @@ -12,6 +12,8 @@ Item { readonly property var folderIconChoices: FolderIcons.choices signal exportRequested(var paths, string folder) signal smartFolderRequested() + property string viewMode: "list" + signal viewModeRequested(string mode) property var selectedPaths: [] property var selectedFolderPaths: [] property var selectableFolders: [] @@ -232,6 +234,19 @@ Item { property bool rowMenu: false readonly property bool allPinned: root.targetNotes.length > 0 && root.targetNotes.every(n => n.pinned) readonly property bool allFavorite: root.targetNotes.length > 0 && root.targetNotes.every(n => n.favorite) + LiquidPopoverItem { + objectName: "listViewMenuItem" + visible: !menu.rowMenu; height: visible ? implicitHeight : 0 + text: "列表视图"; checkable: true; checked: root.viewMode === "list" + onTriggered: root.viewModeRequested("list") + } + LiquidPopoverItem { + objectName: "galleryViewMenuItem" + visible: !menu.rowMenu; height: visible ? implicitHeight : 0 + text: "画廊视图"; checkable: true; checked: root.viewMode === "gallery" + onTriggered: root.viewModeRequested("gallery") + } + Rectangle { visible: !menu.rowMenu; height: visible ? 1 : 0; width: parent.width; color: AppTheme.border } LiquidPopoverItem { objectName: "selectNotesMenuItem" text: root.noteSelectionMode ? "取消选择笔记" : "选择笔记" diff --git a/qml/NotesSettings.qml b/qml/NotesSettings.qml index deed007..b8c78f0 100644 --- a/qml/NotesSettings.qml +++ b/qml/NotesSettings.qml @@ -8,18 +8,23 @@ LiquidDialog { id: root required property var hostWindow required property var controller - required property var session - required property var editorSource property int section: 0 property int bodySize: controller.preference("bodySize", 16) signal actionRequested(string action) title: "设置"; closeText: "完成"; standardButtons: Dialog.Close width: Math.min(620, parent.width - 40) - height: Math.min(section === 0 ? 520 : section === 2 ? 580 : 640, parent.height - 40) - readonly property bool managedNote: !!controller.path && !controller.externalDocument - readonly property bool canExport: !!controller.path && editorSource.webReady && !session.sourceMode && !session.composing && !session.webLoading && !controller.busy - function show(sectionIndex) { section = sectionIndex || 0; open() } - function request(action) { close(); Qt.callLater(function() { root.actionRequested(action) }) } + height: Math.min(580, parent.height - 40) + property string pendingAction: "" + function resetScroll() { settingsScroll.contentItem.contentY = 0 } + function show(sectionIndex) { section = sectionIndex || 0; pendingAction = ""; resetScroll(); open() } + function request(action) { pendingAction = action; close() } + onClosed: { + const action = pendingAction + pendingAction = "" + if (action) actionRequested(action) + } + onSectionChanged: resetScroll() + onHeightChanged: Qt.callLater(revealFocus) function revealFocus() { const item = hostWindow.activeFocusItem, flick = settingsScroll.contentItem if (!visible || !item || !flick || !flick.contentItem) return @@ -42,8 +47,8 @@ LiquidDialog { spacing: 18 LiquidSegmentedControl { Layout.fillWidth: true; implicitHeight: 30; materialForm: true - labels: ["通用", "数据", "高级"]; currentIndex: root.section - onSelectionRequested: function(index) { root.section = index; settingsScroll.contentItem.contentY = 0 } + labels: ["通用", "笔记管理", "数据"]; currentIndex: root.section + onSelectionRequested: function(index) { root.section = index } } ScrollView { id: settingsScroll; Layout.fillWidth: true; Layout.fillHeight: true @@ -70,7 +75,8 @@ LiquidDialog { Accessible.name: "正文字号"; materialForm: true appearanceMode: AppTheme.appearanceMode plainTrigger: true; indicatorStyle: "upDown"; indicatorBackground: true - popupMaterialForm: true; compactPopup: true; neutralOptionHighlight: true + popupMaterialForm: true; compactPopup: true; popupFontWeight: Font.Normal + popupBorderWidth: 0; popupShadowEnabled: true; neutralOptionHighlight: true model: Array.from({length: 13}, (_, i) => String(12 + i)) currentIndex: Math.max(0, Math.min(12, root.bodySize - 12)) onActivated: function(index) { root.bodySize = 12 + index; root.controller.setPreference("bodySize", root.bodySize) } @@ -91,33 +97,32 @@ LiquidDialog { ActionRow { title: "键盘快捷键"; actionText: "查看…"; onTriggered: root.request("shortcuts") } } SettingsGroup { - visible: root.section === 1; title: "导入笔记" + visible: root.section === 2; title: "导入笔记" ActionRow { title: "从 Markdown 导入"; description: "收集为独立笔记,原文件保持不变。"; actionText: "选择笔记…"; enabled: !root.controller.busy; onTriggered: root.request("importFiles") } ActionRow { title: "整个文件夹"; description: "可保留原有分类层级。"; actionText: "导入…"; enabled: !root.controller.busy; onTriggered: root.request("importFolder") } ActionRow { title: "导入记录"; actionText: "查看…"; enabled: !root.controller.busy; onTriggered: root.request("importHistory") } ActionRow { visible: !!root.controller.legacyLibrary; title: "之前的笔记"; actionText: "迁移…"; enabled: !root.controller.busy; onTriggered: root.request("importLegacy") } } SettingsGroup { - visible: root.section === 1; title: "导出" + visible: root.section === 2; title: "导出" ActionRow { title: "全部笔记"; description: "导出 Markdown 和关联附件。"; actionText: "导出…"; enabled: !root.controller.busy; onTriggered: root.request("exportAll") } } SettingsGroup { - visible: root.section === 1; title: "备份与恢复" + visible: root.section === 2; title: "备份与恢复" ActionRow { title: "完整备份"; description: "保留笔记、附件、历史和最近删除。"; actionText: "创建备份…"; enabled: !root.controller.busy; onTriggered: root.request("backup") } ActionRow { title: "恢复完整备份"; description: "替换当前空间前,自动保留一份恢复前副本。"; actionText: "选择备份…"; enabled: !root.controller.busy; onTriggered: root.request("restore") } } SettingsGroup { - visible: root.section === 2; title: "整理与记录" + visible: root.section === 1; title: "整理与记录" ActionRow { title: "笔记模板"; onTriggered: root.request("templates"); enabled: !root.controller.busy } - ActionRow { title: "视图"; description: "保存当前搜索和分类条件。"; actionText: "保存为视图…"; enabled: !root.controller.busy; onTriggered: root.request("smartCollection") } + ActionRow { title: "智能视图"; description: "保存当前搜索和分类条件,自动汇集符合条件的笔记。"; actionText: "保存当前搜索…"; enabled: !root.controller.busy; onTriggered: root.request("smartCollection") } ActionRow { title: "批量重命名标签"; enabled: !root.controller.busy; onTriggered: root.request("renameTags") } ActionRow { title: "恢复草稿"; description: "找回未完成保存的内容。"; actionText: "查看…"; enabled: !root.controller.busy; onTriggered: root.request("recovery") } } SettingsGroup { - visible: root.section === 2; title: "外部文件与源码" + visible: root.section === 2; title: "外部文件" ActionRow { title: "临时编辑 Markdown"; description: "直接编辑原文件,不加入我的笔记。"; actionText: "选择文件…"; enabled: !root.controller.busy; onTriggered: root.request("external") } - ActionRow { title: "当前笔记的 Markdown 源码"; actionText: root.session.sourceMode ? "返回正文" : "查看源码"; enabled: !!root.controller.path && !root.controller.busy && !root.session.composing && !root.session.webLoading; onTriggered: root.request("sourceMode") } } } } diff --git a/tests/chrome/tst_browser.qml b/tests/chrome/tst_browser.qml new file mode 100644 index 0000000..e31019d --- /dev/null +++ b/tests/chrome/tst_browser.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "NoteBrowserViews" + when: windowShown + visible: true + width: 900; height: 700 + QtObject { + id: controller + property var notes: Array.from({length: 12}, (_, i) => ({id: String(i), path: i + ".md"})) + readonly property var noteModel: notes + } + QtObject { + id: organization + property string selectionAnchor: "" + property var selectedPaths: [] + readonly property bool selectionMode: selectedPaths.length > 0 + function selectAll() { selectedPaths = controller.notes.map(note => note.path) } + function toggle(path) { selectedPaths = selectedPaths.includes(path) ? selectedPaths.filter(p => p !== path) : selectedPaths.concat([path]) } + function clearSelection() { selectedPaths = [] } + function click(note, index, modifiers) { + const start = controller.notes.findIndex(n => n.path === selectionAnchor) + selectedPaths = controller.notes.slice(Math.min(start, index), Math.max(start, index) + 1).map(n => n.path) + } + } + NotesBrowser { + id: browser + width: 720; height: 500 + controller: controller; organization: organization + noteDelegate: Component { + ItemDelegate { + required property var modelData + required property int index + width: browser.gallery ? browser.activeView.cellWidth - 12 : browser.width + height: browser.gallery ? 104 : 60 + function activate() {} + } + } + } + function press(key, modifiers) { + const event = {key: key, modifiers: modifiers || Qt.NoModifier, accepted: false, isAutoRepeat: false} + browser.handleKey(event) + verify(event.accepted) + } + function init() { + browser.viewMode = "list" + organization.selectionAnchor = "" + organization.selectedPaths = [] + wait(20) + browser.setCursor(0) + } + function test_gallery_moves_by_row_and_preserves_selection_on_switch() { + organization.selectedPaths = ["1.md", "2.md"] + organization.selectionAnchor = "1.md" + browser.setCursor(2) + browser.viewMode = "gallery" + tryCompare(browser.activeView, "currentIndex", 2) + compare(browser.columns, 3) + compare(organization.selectedPaths.join(), "1.md,2.md") + compare(organization.selectionAnchor, "1.md") + press(Qt.Key_Down) + compare(browser.activeView.currentIndex, 5) + press(Qt.Key_Left) + compare(browser.activeView.currentIndex, 4) + press(Qt.Key_Down, Qt.ShiftModifier) + compare(organization.selectedPaths.join(), "1.md,2.md,3.md,4.md,5.md,6.md,7.md") + browser.viewMode = "list" + tryCompare(browser.activeView, "currentIndex", 7) + compare(organization.selectionAnchor, "1.md") + } + function test_gallery_keeps_two_columns_at_minimum_width() { + browser.viewMode = "gallery" + browser.width = 320 + compare(browser.columns, 2) + browser.width = 720 + compare(browser.columns, 3) + } + function test_cursor_follows_identity_after_model_reorder() { + browser.setCursor(3) + const original = controller.notes.slice() + controller.notes = original.slice().reverse() + tryCompare(browser.activeView, "currentIndex", 8) + compare(browser.cursorKey, "3\n3.md") + controller.notes = original + tryCompare(browser.activeView, "currentIndex", 3) + } +} diff --git a/tests/chrome/tst_folders.qml b/tests/chrome/tst_folders.qml new file mode 100644 index 0000000..03b55c5 --- /dev/null +++ b/tests/chrome/tst_folders.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "FolderBrowserViews" + when: windowShown + visible: true + width: 900; height: 700 + QtObject { + id: organization + property bool folderSelectionMode: false + readonly property bool selectionMode: folderSelectionMode + property var selectedFolderPaths: [] + function beginSelection(kind) { folderSelectionMode = kind === "folders" } + function selectAll() { selectedFolderPaths = browser.model.slice() } + function toggleFolder(path) { selectedFolderPaths = selectedFolderPaths.includes(path) ? selectedFolderPaths.filter(p => p !== path) : selectedFolderPaths.concat([path]) } + function clearSelection() { folderSelectionMode = false; selectedFolderPaths = [] } + } + FolderBrowser { + id: browser + width: 440; height: 400 + organization: organization + model: Array.from({length: 8}, (_, i) => "Parent/" + i) + delegate: ItemDelegate { + required property string modelData + required property int index + width: browser.cellWidth; height: browser.cellHeight + function activate() { testCase.activatedPath = modelData } + function openContext() { testCase.contextPath = modelData } + onActiveFocusChanged: if (activeFocus) { browser.currentIndex = index; browser.cursorKey = modelData } + } + } + property string activatedPath: "" + property string contextPath: "" + function press(key, modifiers) { + const event = {key: key, modifiers: modifiers || Qt.NoModifier, accepted: false, isAutoRepeat: false} + browser.handleKey(event) + verify(event.accepted) + } + function init() { + organization.clearSelection() + browser.viewMode = "list" + browser.currentIndex = 0 + browser.cursorKey = browser.keyAt(0) + activatedPath = ""; contextPath = "" + wait(20) + } + function test_switch_preserves_cursor_and_selection() { + browser.currentIndex = 2 + browser.cursorKey = browser.keyAt(2) + organization.beginSelection("folders") + organization.toggleFolder("Parent/1") + browser.viewMode = "gallery" + wait(20) + compare(browser.currentIndex, 2) + compare(browser.columns, 2) + press(Qt.Key_Down) + compare(browser.currentIndex, 4) + press(Qt.Key_Right) + compare(browser.currentIndex, 5) + compare(organization.selectedFolderPaths.join(), "Parent/1") + browser.viewMode = "list" + wait(20) + compare(browser.currentIndex, 5) + press(Qt.Key_Up) + compare(browser.currentIndex, 4) + press(Qt.Key_Return) + compare(activatedPath, "Parent/4") + press(Qt.Key_Menu) + compare(contextPath, "Parent/4") + } + function test_keyboard_selection_in_gallery() { + browser.viewMode = "gallery" + wait(20) + press(Qt.Key_Space, Qt.ControlModifier) + verify(organization.folderSelectionMode) + compare(organization.selectedFolderPaths.join(), "Parent/0") + press(Qt.Key_A, Qt.ControlModifier) + compare(organization.selectedFolderPaths.length, 8) + press(Qt.Key_Escape) + verify(!organization.selectionMode) + } +} diff --git a/tests/chrome/tst_selection.qml b/tests/chrome/tst_selection.qml index 11218a2..bf2b36a 100644 --- a/tests/chrome/tst_selection.qml +++ b/tests/chrome/tst_selection.qml @@ -45,6 +45,31 @@ TestCase { controller = createTemporaryObject(controllerComponent, testCase) organizer = createTemporaryObject(organizerComponent, testCase, {controller: controller}) } + function test_view_choices_belong_to_browser_menu() { + organizer.openMenu(menuButton, "") + const menu = findChild(organizer, "noteListMenu") + const listView = findChild(organizer, "listViewMenuItem") + const galleryView = findChild(organizer, "galleryViewMenuItem") + tryCompare(menu, "visible", true) + verify(listView.visible) + verify(galleryView.visible) + verify(listView.checked) + const spy = signalSpyComponent.createObject(testCase, {target: organizer, signalName: "viewModeRequested"}) + galleryView.triggered() + compare(spy.count, 1) + compare(spy.signalArguments[0][0], "gallery") + organizer.viewMode = "gallery" + verify(galleryView.checked) + verify(!listView.checked) + menu.close() + organizer.openMenu(row, "a.md", Qt.point(30, 12)) + tryCompare(menu, "visible", true) + verify(!galleryView.visible) + verify(!listView.visible) + menu.close() + spy.destroy() + } + Component { id: signalSpyComponent; SignalSpy {} } function test_toolbar_can_enter_note_selection_without_an_active_note() { controller.path = "outside-filter.md" organizer.openMenu(menuButton, "") diff --git a/tests/chrome/tst_settings.qml b/tests/chrome/tst_settings.qml new file mode 100644 index 0000000..97ffedd --- /dev/null +++ b/tests/chrome/tst_settings.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "SettingsHandoff" + when: windowShown + visible: true + width: 800; height: 700 + Item { id: scene; anchors.fill: parent } + QtObject { + id: preferences + property bool busy: false + property string legacyLibrary: "" + function preference(key, fallback) { return fallback } + function setPreference(key, value) {} + } + NotesSettings { + id: settings + hostWindow: testCase.Window.window; controller: preferences; sourceItem: scene + } + SignalSpy { id: actions; target: settings; signalName: "actionRequested" } + function cleanup() { settings.close(); tryCompare(settings, "visible", false); actions.clear() } + function test_action_waits_until_dialog_is_closed() { + settings.show(0) + tryCompare(settings, "opened", true) + const initialHeight = settings.height + settings.section = 1 + compare(settings.height, initialHeight) + settings.section = 2 + compare(settings.height, initialHeight) + settings.request("templates") + tryCompare(actions, "count", 1) + verify(!settings.visible) + compare(actions.signalArguments[0][0], "templates") + compare(settings.pendingAction, "") + settings.show(0) + tryCompare(settings, "opened", true) + settings.close() + tryCompare(settings, "visible", false) + compare(actions.count, 1) + } +} From 76754cccfd04f014000412fd43d477680206a769 Mon Sep 17 00:00:00 2001 From: liubw Date: Thu, 12 Nov 2026 16:38:42 +0800 Subject: [PATCH 2/3] fix(editor): use theme yellow for highlights and refine block dragging Replace blue editor accents and text selection with the theme yellow and dark selected text across body, tables, search, source view and the note name. Delay the block handle tooltip, keep drag ghost feedback, and add menu dispatch for editor tools. --- qml/NoteNameEditor.qml | 3 +- qml/SourceEditor.qml | 6 ++-- web/editor/dist/editor.js | 66 ++++++++++++++++++++++++++++++++++----- web/editor/editor.css | 15 ++++++--- web/editor/source-view.js | 2 +- web/editor/tools.js | 44 +++++++++++++++++++++----- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/qml/NoteNameEditor.qml b/qml/NoteNameEditor.qml index fec1e57..67efddd 100644 --- a/qml/NoteNameEditor.qml +++ b/qml/NoteNameEditor.qml @@ -12,6 +12,7 @@ Item { property string savedName: "" readonly property bool editing: nameInput.activeFocus readonly property bool modified: nameInput.text.trim() !== savedName + NotesTokens { id: tokens } implicitHeight: nameColumn.implicitHeight signal submitted() @@ -53,7 +54,7 @@ Item { leftPadding: 0; rightPadding: 0; topPadding: 4; bottomPadding: 4 font.pixelSize: 24; font.weight: Font.DemiBold color: AppTheme.text; placeholderTextColor: AppTheme.mutedText - selectionColor: AppTheme.accent; selectedTextColor: AppTheme.accentText + selectionColor: tokens.primaryYellow; selectedTextColor: "#222222" background: null placeholderText: "未命名笔记" Accessible.name: "笔记名称" diff --git a/qml/SourceEditor.qml b/qml/SourceEditor.qml index fdc057d..2aca4bc 100644 --- a/qml/SourceEditor.qml +++ b/qml/SourceEditor.qml @@ -24,8 +24,7 @@ Item { property string notification: "" function notify(text) { notification = text; noticeTimer.restart() } function action(name, args) { if (webReady) bridge.actionRequested({epoch: session.webDocument().epoch, name: name, args: args || {}}) } - function showShortcuts(anchor) { tools.showShortcuts(anchor) } - function openTools(anchor) { if (!session.sourceMode) tools.openMenu(anchor) } + function openTool(name, originMenu) { if (!session.sourceMode) tools.openTool(name, originMenu) } function toggleOutline(anchor) { tools.toggleOutline(anchor) } function correctSpelling(word) { web.replaceMisspelledWord(word); editorFacade.forceActiveFocus() } function exportBaseName() { return String(root.controller.noteTitle || "笔记").replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0,100) || "笔记" } @@ -46,10 +45,11 @@ Item { property bool restoring: false readonly property bool readOnly: controller.readOnly || session.protectedContent || session.webLoading || exporting function css(color) { return "rgba(" + Math.round(color.r * 255) + "," + Math.round(color.g * 255) + "," + Math.round(color.b * 255) + "," + color.a + ")" } + NotesTokens { id: tokens } readonly property var theme: ({ background: css(AppTheme.contentBackground), text: css(AppTheme.text), muted: css(AppTheme.mutedText), border: css(AppTheme.border), - accent: css(AppTheme.accent), selection: css(AppTheme.accent), selectionText: css(AppTheme.accentText), scheme: AppTheme.dark ? "dark" : "light", + accent: css(tokens.primaryYellow), selection: css(tokens.primaryYellow), selectionText: "#222222", scheme: AppTheme.dark ? "dark" : "light", size: bodySize + "px", width: readingWidth + "px", inset: inset + "px", font: "'Noto Sans CJK SC', sans-serif" }) property string lastOptions: "" diff --git a/web/editor/dist/editor.js b/web/editor/dist/editor.js index 7d98c0e..b933ae9 100644 --- a/web/editor/dist/editor.js +++ b/web/editor/dist/editor.js @@ -87946,7 +87946,7 @@ drawSelection(), EditorView2.editorAttributes.of({ class: "source-viewport" }), EditorView2.contentAttributes.of({ "aria-label": "Markdown \u6E90\u7801\u7F16\u8F91\u5668", spellcheck: "false" }), - EditorView2.theme({ "&": { font: "14px/1.65 monospace", backgroundColor: "transparent" }, ".cm-content": { padding: "12px", minHeight: "75vh" }, ".cm-line": { padding: "0" }, "&.cm-focused": { outline: "none" }, ".cm-scroller": { font: "inherit" }, ".cm-selectionBackground": { backgroundColor: "var(--selection) !important" } }), + EditorView2.theme({ "&": { font: "14px/1.65 monospace", backgroundColor: "transparent" }, ".cm-content": { padding: "12px", minHeight: "75vh" }, ".cm-line": { padding: "0" }, "&.cm-focused": { outline: "none" }, ".cm-scroller": { font: "inherit" }, ".cm-selectionBackground": { backgroundColor: "color-mix(in srgb,var(--selection) 40%,transparent) !important" } }), editable2.of([]), search3.of([]), keymap5.of([ @@ -88189,7 +88189,7 @@ function makeTools(env) { let view2, hover = 0, blockHandleVisible = false, collapsed = /* @__PURE__ */ new Set(), query = "", caseSensitive = false, searchIndex = -1, slashIndex = 0, lastSlash = "", uiTimer, lastUI = "", layoutToken = 0; let cachedDoc, cachedText = "", cachedStats = {}, cachedOutline = [], cachedMatchesDoc, cachedMatchesQuery, cachedMatchesCase, cachedMatches = []; - let suppressedSlash = null, blockSelection = false, newParagraphPos = null, dragPreview = null, contextRequest = null; + let suppressedSlash = null, blockSelection = false, newParagraphPos = null, dragPreview = null, dragGhost = null, dragSource = null, contextRequest = null; let pending = 0, request = 0, clipboardBlock = null; const imports = /* @__PURE__ */ new Map(), bookmarks = /* @__PURE__ */ new Map(); const key4 = new PluginKey("nextnotesTools"); @@ -88421,6 +88421,42 @@ } return { from: block.pos, to: block.pos + block.node.nodeSize }; } + function clearDragGhost() { + dragGhost?.remove(); + dragGhost = null; + dragSource?.classList.remove("editor-drag-source"); + dragSource = null; + } + function updateDragGhost(args) { + if (!Number.isFinite(args.x)) return; + const block = targetBlock(args), dom = block && view2.nodeDOM(block.pos); + if (!(dom instanceof Element)) return; + if (dragSource !== dom) { + clearDragGhost(); + const rect2 = dom.getBoundingClientRect(), clone = dom.cloneNode(true); + clone.classList.remove("editor-selected-block", "ProseMirror-selectednode", "editor-empty-line"); + for (const el of [clone, ...clone.querySelectorAll("*")]) { + el.removeAttribute("id"); + el.removeAttribute("contenteditable"); + el.removeAttribute("data-placeholder"); + el.classList.remove("editor-selected-block", "ProseMirror-selectednode"); + } + dragGhost = document.createElement("div"); + dragGhost.className = "editor-drag-ghost"; + dragGhost.setAttribute("aria-hidden", "true"); + dragGhost.inert = true; + dragGhost.style.width = rect2.width + "px"; + const content4 = document.createElement("div"); + content4.className = "editor-drag-content ProseMirror"; + content4.append(clone); + dragGhost.append(content4); + document.body.append(dragGhost); + dragSource = dom; + dragSource.classList.add("editor-drag-source"); + } + const rect = dom.getBoundingClientRect(), offset = Math.max(0, Math.min(args.offsetY || 0, rect.height)); + dragGhost.style.transform = `translate3d(${args.x + 18}px,${args.y - offset + 8}px,0)`; + } function dropTarget(args) { const r2 = range(args); if (!r2 || !Number.isFinite(args.y)) return null; @@ -88444,6 +88480,8 @@ target = after ? after.pos + after.node.nodeSize : void 0; } else if (typeof args.y === "number") target = dropTarget(args)?.pos; if (target === void 0 || target >= r2.from && target <= r2.to) return false; + clearDragGhost(); + dragPreview = null; const content4 = view2.state.doc.slice(r2.from, r2.to).content; let tr = view2.state.tr.delete(r2.from, r2.to); const at = tr.mapping.map(target, -1); @@ -88550,8 +88588,11 @@ return true; } if (name2 === "cancelBlockMove") { + const active3 = !!dragPreview; dragPreview = null; - schedule(); + clearDragGhost(); + if (active3) refresh(); + else schedule(); return true; } if (name2 === "search") { @@ -88605,17 +88646,20 @@ } if (!editable2()) return false; if (name2 === "previewBlockMove") { + const first = !dragPreview; dragPreview = args; - schedule(); + updateDragGhost(args); + if (first) refresh(); + else schedule(); return true; } if (name2 === "slashChoose") return applySlash(args.index); if (name2 === "selectBlock") { const b = targetBlock(args); if (!b) return false; - const selection = args.extend ? TextSelection.between(view2.state.doc.resolve(Math.min(view2.state.selection.from, b.pos)), view2.state.doc.resolve(Math.max(view2.state.selection.to, b.pos + b.node.nodeSize))) : NodeSelection.create(view2.state.doc, b.pos); + const selection = args.extend ? TextSelection.between(view2.state.doc.resolve(Math.min(view2.state.selection.from, b.pos)), view2.state.doc.resolve(Math.max(view2.state.selection.to, b.pos + b.node.nodeSize))) : args.focus === false && b.node.isTextblock ? TextSelection.create(view2.state.doc, b.pos + 1, b.pos + b.node.nodeSize - 1) : NodeSelection.create(view2.state.doc, b.pos); view2.dispatch(view2.state.tr.setSelection(selection).setMeta(key4, "block-selection")); - view2.focus(); + if (args.focus !== false) view2.focus(); return true; } if (name2 === "moveBlock") return moveBlock(args); @@ -88790,7 +88834,12 @@ props: { attributes: () => blockSelection ? { class: "editor-block-selection" } : {}, decorations(state) { - const base2 = key4.getState(state), { $from, empty: empty3 } = state.selection; + let base2 = key4.getState(state); + const { $from, empty: empty3 } = state.selection; + if (dragPreview) { + const b = targetBlock(dragPreview); + if (b) base2 = base2.add(state.doc, [Decoration.node(b.pos, b.pos + b.node.nodeSize, { class: "editor-drag-source" })]); + } if (blockSelection && !empty3) { const items = []; state.doc.nodesBetween(state.selection.from, state.selection.to, (node2, pos2) => { @@ -88855,9 +88904,11 @@ observer.observe(v2.dom); schedule(); return { update() { + if (dragSource?.isConnected) dragSource.classList.add("editor-drag-source"); env.refreshToc?.(); schedule(); }, destroy() { + clearDragGhost(); observer.disconnect(); clearTimeout(uiTimer); uiTimer = null; @@ -88876,6 +88927,7 @@ exportReady, hasPending: () => pending > 0, reset() { + clearDragGhost(); clearTimeout(uiTimer); uiTimer = null; collapsed.clear(); diff --git a/web/editor/editor.css b/web/editor/editor.css index 3fb1022..22ba04c 100644 --- a/web/editor/editor.css +++ b/web/editor/editor.css @@ -1,4 +1,4 @@ -:root { color-scheme:var(--scheme,light); --background:transparent; --text:#222; --muted:#777; --border:#ccc; --accent:#007aff; --selection:#cce2ff; --size:16px; --inset:24px; --width:740px; --font:sans-serif; } +:root { color-scheme:var(--scheme,light); --background:transparent; --text:#222; --muted:#777; --border:#ccc; --accent:#ffb921; --selection:#ffb921; --selectionText:#222222; --size:16px; --inset:24px; --width:740px; --font:sans-serif; } * { box-sizing:border-box; }html,body { margin:0;min-height:100%;background:var(--background);color:var(--text);font:var(--size)/1.65 var(--font); } #editor { max-width:calc(var(--width) + 2 * var(--inset));padding:18px var(--inset) 72px;margin:auto; } .ProseMirror { outline:none;min-height:calc(100vh - 90px);overflow-wrap:anywhere;white-space:pre-wrap; } @@ -13,11 +13,17 @@ pre {padding:.8em 1em;border-radius:6px;background:color-mix(in srgb,var(--text) a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto;}hr{border:0;border-top:1px solid var(--border);margin:1.5em 0;}table{border-collapse:collapse;width:100%;table-layout:fixed;margin:1em 0;}td,th{border:1px solid var(--border);padding:.4em .65em;vertical-align:top;min-width:3em;position:relative;}th{font-weight:600;background:color-mix(in srgb,var(--text) 5%,transparent);}td>p,th>p{margin:0;}.selectedCell::after{position:absolute;inset:0;background:var(--selection);opacity:.45;content:'';pointer-events:none;}.column-resize-handle{position:absolute;right:-2px;top:0;bottom:-2px;width:4px;background:var(--accent);pointer-events:none;}.resize-cursor{cursor:col-resize;}::selection{background:var(--selection);color:var(--selectionText,white);}#error{padding:0 20px;color:var(--muted);}.ProseMirror-selectednode{outline:none;}.ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute;}.ProseMirror-gapcursor:after{content:'';display:block;position:absolute;top:-2px;width:20px;border-top:1px solid var(--text);animation:blink 1s steps(2,start) infinite;}.ProseMirror-focused .ProseMirror-gapcursor{display:block;}@keyframes blink{to{visibility:hidden;}} /* Editing aids never alter the Markdown document. */ .ProseMirror .editor-selected-block,.ProseMirror .ProseMirror-selectednode{background-color:color-mix(in srgb,var(--text) 8%,transparent);border-radius:3px;outline:none;} -.ProseMirror.editor-block-selection::selection,.ProseMirror.editor-block-selection ::selection,.ProseMirror-hideselection::selection,.ProseMirror-hideselection ::selection{background:transparent;color:inherit;} +:root[style*="--scheme: dark"] .ProseMirror .editor-selected-block,:root[style*="--scheme: dark"] .ProseMirror .ProseMirror-selectednode{background-color:color-mix(in srgb,var(--text) 22%,transparent);} +.ProseMirror.editor-block-selection::selection,.ProseMirror.editor-block-selection ::selection,.ProseMirror-hideselection::selection,.ProseMirror-hideselection ::selection{background:transparent;color:var(--text);} .ProseMirror-hideselection{caret-color:transparent;} +.ProseMirror .editor-drag-source{opacity:.35;} +.editor-drag-ghost{position:fixed;left:0;top:0;z-index:1000;max-width:calc(100vw - 32px);max-height:240px;overflow:hidden;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--background);box-shadow:0 8px 24px #0005;opacity:.85;pointer-events:none;user-select:none;will-change:transform;} +.editor-drag-content.ProseMirror{min-height:0;} +.editor-drag-content>:first-child{margin-top:0;} +.editor-drag-content>:last-child{margin-bottom:0;} .editor-empty-line{position:relative;} .ProseMirror-focused[contenteditable="true"] p.editor-empty-line::before{content:attr(data-placeholder);position:absolute;top:0;left:0;max-width:100%;overflow:hidden;white-space:nowrap;color:color-mix(in srgb,var(--muted) 65%,transparent);pointer-events:none;user-select:none;} -.folded-content{display:none!important;}.folded-heading:after{content:' …';font-size:.7em;color:var(--muted);}.editor-search-match{background:color-mix(in srgb,#f7c948 55%,transparent);color:inherit;} +.folded-content{display:none!important;}.folded-heading:after{content:' …';font-size:.7em;color:var(--muted);}.editor-search-match{background:color-mix(in srgb,var(--selection) 55%,transparent);color:inherit;} .editor-code{margin:1em 0;}.editor-code pre{margin:0;}.editor-code[data-language]:before{content:attr(data-language);display:block;font:11px/1.7 var(--font);color:var(--muted);}.diagram-preview{padding:12px 0;overflow:auto;}.diagram-preview[hidden]{display:none;}.diagram-preview img{display:block;margin:auto;max-height:620px;} .token.comment,.token.prolog,.token.doctype,.token.cdata{color:#6c7986;}.token.punctuation{color:#637184;}.token.property,.token.tag,.token.boolean,.token.number,.token.constant,.token.symbol{color:#a33a57;}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin{color:#237a47;}.token.operator,.token.entity,.token.url{color:#946420;}.token.atrule,.token.attr-value,.token.keyword{color:#7051b8;}.token.function,.token.class-name{color:#246aa6;} :root[style*="--scheme: dark"] .token.string{color:#9fc78b;}:root[style*="--scheme: dark"] .token.keyword{color:#c7a2e6;}:root[style*="--scheme: dark"] .token.number{color:#ef9ca8;} @@ -25,6 +31,7 @@ a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto; [data-type="footnote_definition"],.footnote-definition{font-size:.9em;color:var(--muted);} @page{size:A4;margin:16mm 17mm 18mm;} @media print{ + .editor-drag-ghost{display:none!important;}.ProseMirror .editor-drag-source{opacity:1;} .editor-selected-block,.ProseMirror-selectednode{background-color:transparent!important;} .editor-empty-line::before{content:none!important;} :root{color-scheme:light;--background:white;--text:#111;--muted:#555;--border:#bbb;--accent:#165ea6;} @@ -39,4 +46,4 @@ a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto; .wiki-completion { position: fixed; z-index: 100; max-height: 230px; width: 350px; overflow: auto; background: var(--background); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 4px; } .wiki-completion button { display: block; width: 100%; background: transparent; color: inherit; border: 0; text-align: left; padding: 8px; } -.wiki-completion button[aria-selected="true"] { background: color-mix(in srgb,var(--text) 12%,transparent); } +.wiki-completion button[aria-selected="true"] { background: var(--selection); color: var(--selectionText); } diff --git a/web/editor/source-view.js b/web/editor/source-view.js index 69c4006..bf8d6e5 100644 --- a/web/editor/source-view.js +++ b/web/editor/source-view.js @@ -19,7 +19,7 @@ export function sourceNodeView(env){return (node,outer,getPos,_decorations,inner EditorView.lineWrapping,drawSelection(), EditorView.editorAttributes.of({class:'source-viewport'}), EditorView.contentAttributes.of({'aria-label':'Markdown 源码编辑器',spellcheck:'false'}), - EditorView.theme({'&':{font:'14px/1.65 monospace',backgroundColor:'transparent'},'.cm-content':{padding:'12px',minHeight:'75vh'},'.cm-line':{padding:'0'},'&.cm-focused':{outline:'none'},'.cm-scroller':{font:'inherit'},'.cm-selectionBackground':{backgroundColor:'var(--selection) !important'}}), + EditorView.theme({'&':{font:'14px/1.65 monospace',backgroundColor:'transparent'},'.cm-content':{padding:'12px',minHeight:'75vh'},'.cm-line':{padding:'0'},'&.cm-focused':{outline:'none'},'.cm-scroller':{font:'inherit'},'.cm-selectionBackground':{backgroundColor:'color-mix(in srgb,var(--selection) 40%,transparent) !important'}}), editable.of([]),search.of([]), keymap.of([ {key:'Mod-z',run:()=>env.command('undo')}, diff --git a/web/editor/tools.js b/web/editor/tools.js index 8add318..73181d3 100644 --- a/web/editor/tools.js +++ b/web/editor/tools.js @@ -14,7 +14,7 @@ const slashEntries=[ export function makeTools(env){ let view,hover=0,blockHandleVisible=false,collapsed=new Set(),query='',caseSensitive=false,searchIndex=-1,slashIndex=0,lastSlash='',uiTimer,lastUI='',layoutToken=0; let cachedDoc,cachedText='',cachedStats={},cachedOutline=[],cachedMatchesDoc,cachedMatchesQuery,cachedMatchesCase,cachedMatches=[]; - let suppressedSlash=null,blockSelection=false,newParagraphPos=null,dragPreview=null,contextRequest=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); + let suppressedSlash=null,blockSelection=false,newParagraphPos=null,dragPreview=null,dragGhost=null,dragSource=null,contextRequest=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); const key=new PluginKey('nextnotesTools'); const editable=()=>env.canEdit()&&!env.busy(); const notify=text=>env.bridge()?.notice(text); @@ -142,6 +142,32 @@ export function makeTools(env){ if(args.selection&&!view.state.selection.empty){const first=topBlock(view.state.selection.from),last=topBlock(Math.max(view.state.selection.from,view.state.selection.to-1));if(block.pos>=first.pos&&block.pos<=last.pos)return {from:first.pos,to:last.pos+last.node.nodeSize};} return {from:block.pos,to:block.pos+block.node.nodeSize}; } + function clearDragGhost(){ + dragGhost?.remove();dragGhost=null; + dragSource?.classList.remove('editor-drag-source');dragSource=null; + } + function updateDragGhost(args){ + // Legacy callers can preview a boundary without supplying a pointer position. + if(!Number.isFinite(args.x))return; + const block=targetBlock(args),dom=block&&view.nodeDOM(block.pos); + if(!(dom instanceof Element))return; + if(dragSource!==dom){ + clearDragGhost(); + const rect=dom.getBoundingClientRect(),clone=dom.cloneNode(true); + clone.classList.remove('editor-selected-block','ProseMirror-selectednode','editor-empty-line'); + for(const el of [clone,...clone.querySelectorAll('*')]){ + el.removeAttribute('id');el.removeAttribute('contenteditable');el.removeAttribute('data-placeholder'); + el.classList.remove('editor-selected-block','ProseMirror-selectednode'); + } + dragGhost=document.createElement('div');dragGhost.className='editor-drag-ghost'; + dragGhost.setAttribute('aria-hidden','true');dragGhost.inert=true; + dragGhost.style.width=rect.width+'px'; + const content=document.createElement('div');content.className='editor-drag-content ProseMirror';content.append(clone);dragGhost.append(content); + document.body.append(dragGhost);dragSource=dom;dragSource.classList.add('editor-drag-source'); + } + const rect=dom.getBoundingClientRect(),offset=Math.max(0,Math.min(args.offsetY||0,rect.height)); + dragGhost.style.transform=`translate3d(${args.x+18}px,${args.y-offset+8}px,0)`; + } // Preview and release share one calculation: the line marks a real block boundary. // Ignore folded nodes, and use the gap between visible blocks rather than pointer y. function dropTarget(args){ @@ -163,6 +189,7 @@ export function makeTools(env){ else if(args.direction==='down'){const after=blocks.find(b=>b.pos>=r.to);target=after?after.pos+after.node.nodeSize:undefined;} else if(typeof args.y==='number')target=dropTarget(args)?.pos; if(target===undefined||target>=r.from&&target<=r.to)return false; + clearDragGhost();dragPreview=null; const content=view.state.doc.slice(r.from,r.to).content;let tr=view.state.tr.delete(r.from,r.to); const at=tr.mapping.map(target,-1);tr=tr.insert(at,content); tr.setMeta('nextnotesMovedBlock',{from:r.from,to:r.to,at}); @@ -214,7 +241,7 @@ export function makeTools(env){ const hit=view.posAtCoords({left:args.x,top:args.y}); contextRequest={request:args.request,pos:hit?.pos??view.state.selection.from};ui();return true; } - if(name==='cancelBlockMove'){dragPreview=null;schedule();return true;} + if(name==='cancelBlockMove'){const active=!!dragPreview;dragPreview=null;clearDragGhost();if(active)refresh();else schedule();return true;} if(name==='search'){query=args.query||'';caseSensitive=!!args.caseSensitive;searchIndex=-1;refresh();return true;} if(name==='findNext')return navigateSearch(args.direction||1); if(name==='dragScroll'){window.scrollBy(0,args.delta||0);schedule();return true;} @@ -229,12 +256,12 @@ export function makeTools(env){ let tr=view.state.tr;for(const m of [...chosen].reverse())tr=tr.insertText(args.replacement||'',m.from,m.to);commit(tr);notify(`已替换 ${chosen.length} 处`);return true; } if(!editable())return false; - if(name==='previewBlockMove'){dragPreview=args;schedule();return true;} + if(name==='previewBlockMove'){const first=!dragPreview;dragPreview=args;updateDragGhost(args);if(first)refresh();else schedule();return true;} if(name==='slashChoose')return applySlash(args.index); if(name==='selectBlock'){ const b=targetBlock(args);if(!b)return false; - const selection=args.extend?TextSelection.between(view.state.doc.resolve(Math.min(view.state.selection.from,b.pos)),view.state.doc.resolve(Math.max(view.state.selection.to,b.pos+b.node.nodeSize))):NodeSelection.create(view.state.doc,b.pos); - view.dispatch(view.state.tr.setSelection(selection).setMeta(key,'block-selection'));view.focus();return true; + const selection=args.extend?TextSelection.between(view.state.doc.resolve(Math.min(view.state.selection.from,b.pos)),view.state.doc.resolve(Math.max(view.state.selection.to,b.pos+b.node.nodeSize))):args.focus===false&&b.node.isTextblock?TextSelection.create(view.state.doc,b.pos+1,b.pos+b.node.nodeSize-1):NodeSelection.create(view.state.doc,b.pos); + view.dispatch(view.state.tr.setSelection(selection).setMeta(key,'block-selection'));if(args.focus!==false)view.focus();return true; } if(name==='moveBlock')return moveBlock(args); if(name==='duplicateBlock'||name==='deleteBlock'||name==='copyBlock'){ @@ -335,7 +362,8 @@ export function makeTools(env){ }}, props:{attributes:()=>blockSelection?{class:'editor-block-selection'}:{}, decorations(state){ - const base=key.getState(state),{$from,empty}=state.selection; + let base=key.getState(state);const {$from,empty}=state.selection; + if(dragPreview){const b=targetBlock(dragPreview);if(b)base=base.add(state.doc,[Decoration.node(b.pos,b.pos+b.node.nodeSize,{class:'editor-drag-source'})]);} if(blockSelection&&!empty){ const items=[]; state.doc.nodesBetween(state.selection.from,state.selection.to,(node,pos)=>{ @@ -371,10 +399,10 @@ export function makeTools(env){ } } }, - view(v){view=v;const observer=new ResizeObserver(schedule);observer.observe(v.dom);schedule();return {update(){env.refreshToc?.();schedule();},destroy(){observer.disconnect();clearTimeout(uiTimer);uiTimer=null;}};} + view(v){view=v;const observer=new ResizeObserver(schedule);observer.observe(v.dom);schedule();return {update(){if(dragSource?.isConnected)dragSource.classList.add('editor-drag-source');env.refreshToc?.();schedule();},destroy(){clearDragGhost();observer.disconnect();clearTimeout(uiTimer);uiTimer=null;}};} }); return {plugin,run,refresh,state:()=>lastUI?JSON.parse(lastUI):{},ui:schedule,uiNow:ui,uploader,importResult,exportReady,hasPending:()=>pending>0, - reset(){clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;blockHandleVisible=false;blockSelection=false;newParagraphPos=null;dragPreview=null;contextRequest=null;suppressedSlash=null;slashIndex=0;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, + reset(){clearDragGhost();clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;blockHandleVisible=false;blockSelection=false;newParagraphPos=null;dragPreview=null;contextRequest=null;suppressedSlash=null;slashIndex=0;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, selectedCode:()=>topBlock(view.state.selection.from), }; } From 5d06845dfcb69e777ce943e0d5a9311e03871831 Mon Sep 17 00:00:00 2001 From: liubw Date: Thu, 12 Nov 2026 16:38:53 +0800 Subject: [PATCH 3/3] chore(release): 0.1.2 --- CMakeLists.txt | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c4d330c..93d5852 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" NEXTNOTES_VERSION LIMIT_COUNT 1) project(NextNotes VERSION ${NEXTNOTES_VERSION} LANGUAGES CXX) -set(NEXTNOTES_RELEASE_DATE "2026-11-09" CACHE STRING "AppStream release date") +set(NEXTNOTES_RELEASE_DATE "2026-11-12" CACHE STRING "AppStream release date") set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 QuickDialogs2 Svg WebEngineCore WebEngineQuick WebChannel) diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2